Files
OSIT-AE-App-Svelte/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md
Scott Idem 3c8a6feda0 Docs: Document session cold-start bug fix and mark project resolved
- Updated Dexie/liveQuery guide with detailed explanation of the
  try_cache + microtask yield bug pattern
- Marked session view refactor project as RESOLVED (2026-02-26)
- Added inline code comments to all three fixed loader functions
  explaining the critical fixes

Documents the 'refresh twice' bug resolution for future reference.
2026-02-26 13:43:34 -05:00

18 KiB

Stability Patterns for liveQuery + Svelte 5

Dexie's liveQuery works well with Svelte 5 runes, but the combination requires a few stable patterns so queries don't get recreated unintentionally and components render correctly on a "cold start" (empty IndexedDB).

  • Keep the observable instance stable: wrap liveQuery in a stable $derived so the observable isn't recreated on every render. Recreate the liveQuery only when explicit dependencies change (IDs, filters, or search keys).
// stable derived wrapper — only recreated when `id` changes
let lq__obj = $derived(
    (() => {
    // capture the dependency(s) in a single stable closure
    const id = url_id;
    return liveQuery(async () => {
            if (!id) return null;
            console.log('[LQ] running for id=', id);
            return await db.table.get(id);
        });
    })()
);
  • Use $derived.by(() => ...) where available in your runes shim to build queries from a computed set of inputs (IDs list, search params). This preserves a stable observable instance while still reacting to explicit dependency changes.

  • Avoid capturing mutable objects or inline expressions in the liveQuery closure. If the closure captures a changing reference, the query may be recreated unexpectedly or miss the first write.

Common Gotchas and Fixes (Why things sometimes need multiple refreshes)

  • Cold start (IDB empty) + non-blocking API writes: If you mount a component before data is written to IDB, liveQuery may run against an empty DB. The API write will populate IDB later, but sometimes a chain of dependent queries (e.g., presentations -> presenters) won't all rerun in the order you expect. The symptoms you described — session shows after one refresh, presenters only after a second — are consistent with either (a) queries recreated in the wrong order or (b) dependent store values being set only after some subscriptions are already created.

Critical Discovery (2026-02-26): The "try_cache: false" Bug

Symptom: Nested data (e.g., Session → Presentations → Presenters) requires multiple manual refreshes to display on cold-start, even when using blocking loads.

Root Cause: Two interconnected issues in nested data loaders:

  1. Disabled caching in nested loads: Parent loads were passing try_cache: false to child loads, meaning presentations and presenters were fetched from API but never written to IndexedDB.
  2. Missing microtask yields: Even when caching was enabled, components would mount and subscribe to liveQuery before IndexedDB writes completed, causing race conditions.

Example of the Bug:

// Session loader (BROKEN)
await db_save_ae_obj_li__ae_obj({ table: 'session', obj_li: [session] });
// Loads presentations but disables caching ❌
return await load_presentations({ ..., try_cache: false });
  // Presentations fetch from API ✅
  // Presentations SKIP IndexedDB write ❌
  // Presenters SKIP IndexedDB write ❌
// Component mounts, liveQuery finds only session ❌

The Fix:

// Session loader (FIXED)
await db_save_ae_obj_li__ae_obj({ table: 'session', obj_li: [session] });
await Promise.resolve(); // Yield to observers
// Preserve parent's try_cache value ✅
return await load_presentations({ ..., try_cache });
  // Presentations fetch AND write to IDB ✅
  await Promise.resolve(); // Yield to observers
  // Presenters fetch AND write to IDB ✅
  await Promise.resolve(); // Yield to observers
// Component mounts, liveQuery finds all data ✅

Key Lessons:

  1. Always preserve try_cache through nested loads unless you have a specific reason to disable caching for that operation
  2. Add await Promise.resolve() after IndexedDB writes to ensure Dexie's liveQuery observers fire before the function returns
  3. Block on nested loads with await Promise.all() instead of fire-and-forget forEach() when the page needs complete data for first render

Fixes:

  • Prefer the "Blocking Loader" when you can: await the API call in +page.ts so IDB is populated before Svelte mounts.
  • If you cannot block, return an initial_* object from +page.ts and use it as an immediate fallback in your component so the UI renders from that payload while liveQuery takes over for subsequent updates. Example from Aether:
<Comp_event_presentation_obj_li
    lq__event_presentation_obj_li={$lq__event_presentation_obj_li ?? data.initial_session_obj?.event_presentation_li ?? []}
    {log_lvl}
/>
  • Ensure store IDs are set before subscribers that depend on them are created. Use untrack() (or an equivalent non-reactive assignment) to set IDs in stores during initialization so components subscribe to the correct IDs immediately:
$effect(() => {
    if (!ae_acct) return;
    untrack(() => {
        $events_slct.event_id = url_event_id;
        $events_slct.event_session_id = url_session_id;
    });
});
  • When you have chains (presentations depend on session; presenters depend on presentation.person_id), make the dependent liveQuery explicitly wait for the upstream ID and log inside each query to verify the order — adding a small await Promise.resolve() or await 0 inside the liveQuery is sometimes useful during debugging to ensure the JS microtask queue has a chance to settle after DB writes.

Practical Patterns from Aether (Journals & Events)

  • Journals: The journaling pages use SWR-style background refreshes but reliably render because either (a) the page +page.ts blocks to populate DB for critical views, or (b) components accept data.initial_* fallback values until liveQuery emits. This hybrid approach avoids the "refresh twice" problem while keeping navigation snappy.

  • Sessions / Presentations: The session page demonstrates several best practices:

    • Use url_* constants (derived from data.params) so the liveQuery closure captures a stable value instead of the reactive store directly.
    • Provide initial_session_obj from +page.ts as a first-draw fallback to child components.
    • Use $derived.by(() => liveQuery(...)) for presentation lists so the observable instance is stable across renders and recreated only when event_session_id or search changes.

Example (presentation list pattern):

let lq__event_presentation_obj_li = $derived(
    liveQuery(async () => {
        if (!url_session_id) return [];
        console.log('[LQ] Querying Presentations for Session:', url_session_id);
        return await db_events.presentation.where('event_session_id').equals(url_session_id).sortBy('name');
    })
);

Debugging Checklist

  • Add a small console.log inside each liveQuery closure to confirm when it runs and what id it sees.
  • Verify that +page.ts either awaits critical loads or returns initial_* payloads for first-render hydration.
  • Confirm that dependent store values (selected IDs) are assigned before components subscribe — use untrack to prevent extra reactive cycles.
  • Ensure your liveQuery closures return quickly and do not throw; any exception inside the query can stop updates.
  • If a dependent query appears stale, temporarily add await 0 in the upstream query or an explicit Promise.resolve() after the IDB write to force the microtask queue to flush during debugging.

Summary Recommendations

  • Prefer blocking loads for primary views when first-render correctness matters.
  • Use initial_* fallback data when non-blocking loads are required.
  • Wrap liveQuery in stable $derived instances and only recreate when explicit inputs change.
  • Use untrack to set selection IDs during initialization to avoid subscribe-order bugs.
  • Add targeted logs inside liveQuery closures to diagnose ordering and subscription behavior.

These patterns are deliberately conservative — they trade minimal blocking or small explicit fallbacks for predictable first-render behaviour. The Aether app's Journals and Event session pages are working examples of these techniques in practice.

Examples in this repository

The following files demonstrate stable liveQuery usage, initial_* fallbacks, and stable $derived wrappers used across the Aether app. Inspect these for copy/paste patterns and concrete implementations.

Refer to these files when you need concrete code examples to adopt the patterns described above.

References

This document provides a guide to integrating Svelte (with a focus on Runes) and Dexie.js for building reactive web applications. It covers key concepts and best practices for managing reactivity between Svelte components and the Dexie.js database.

Svelte 5 Migration Guide

Svelte 5 introduces "runes" as a new way to manage reactivity. This is a major change from previous versions of Svelte, and it's important to understand the breaking changes before migrating.

Key Breaking Changes

  • let is no longer reactive: In Svelte 4, any let variable declared in the top-level scope of a component was automatically reactive. In Svelte 5, you must explicitly declare reactive state using the $state rune.
  • $: is replaced by $derived and $effect: The $ label is no longer used for reactive statements. Instead, you should use the $derived rune for computed values and the $effect rune for side effects.
  • export let is replaced by $props: Component props are now declared using the $props rune, which provides a more flexible and explicit way to define component APIs.
  • Event handling: The on: directive is replaced by event attributes (e.g., onclick). Component events are now handled using callback props instead of createEventDispatcher.
  • Slots are replaced by snippets: The <slot> element is replaced by the {#snippet ...} block, which provides a more powerful and flexible way to pass content to components.

For a complete list of breaking changes, refer to the Svelte 5 migration guide.

Dexie.js Quick Reference

Dexie.js is a lightweight, minimalistic wrapper for IndexedDB that makes it easier to work with client-side databases.

Key Classes and Methods

  • Dexie: The main class for creating and managing IndexedDB databases.
    • new Dexie(databaseName): Creates a new database instance.
    • version(versionNumber).stores({ ... }): Defines the database schema.
  • Table: Represents an object store (table) in the database.
    • add(item): Adds a new item to the table.
    • put(item): Adds or updates an item in the table.
    • update(key, changes): Updates an existing item.
    • delete(key): Deletes an item by its primary key.
    • get(key): Retrieves an item by its primary key.
    • where(index): Starts a query using an index.
    • toArray(): Retrieves all items from the table as an array.
  • Collection: Represents a collection of items resulting from a query.
    • toArray(): Retrieves all items in the collection as an array.
    • first(): Retrieves the first item in the collection.
    • last(): Retrieves the last item in the collection.
    • each(callback): Iterates over each item in the collection.
    • modify(changes): Updates all items in the collection.
    • delete(): Deletes all items in the collection.

For a complete list of API methods, refer to the Dexie.js API Reference.

Integrating Svelte Runes and Dexie.js

The combination of Svelte Runes and Dexie.js allows for the creation of highly reactive and efficient web applications.

The liveQuery Function

Dexie.js provides a liveQuery function that returns an observable of the query result. This observable can be used to automatically update the UI whenever the data in the database changes.

Using liveQuery with Svelte Runes

To use liveQuery with Svelte Runes, you can create a custom readable store that wraps the liveQuery observable. This store can then be used in your Svelte components to display and interact with the data.

1. Create a liveQuery store:

import { liveQuery } from 'dexie';
import { readable } from 'svelte/store';
import { db } from './db'; // Your Dexie database instance

export function createLiveQueryStore<T>(query: () => T | Promise<T>) {
    return readable<T | undefined>(undefined, (set) => {
        const subscription = liveQuery(query).subscribe({
            next: (result) => set(result),
            error: (error) => console.error(error)
        });
        return () => subscription.unsubscribe();
    });
}

2. Use the createLiveQueryStore in your component:

<script>
    import { createLiveQueryStore } from './stores';
    import { db } from './db';

    const friends = createLiveQueryStore(() => db.friends.toArray());
</script>

<ul>
    {#if $friends} {#each $friends as friend}
    <li>{friend.name}</li>
    {/each} {/if}
</ul>

The createLiveQueryStore function creates a readable store that automatically updates whenever the data in the friends table changes. The $friends variable in the component will always contain the latest data from the database.

Page Load Strategies (Avoiding the "Waterfall")

When loading data for a primary page view (e.g., viewing a specific Journal, Session, or Person), you must choose a synchronization strategy to ensure the UI renders correctly on the first load.

The "Fire & Forget" Anti-Pattern (AVOID)

Triggering a background load in +page.ts without await leads to race conditions.

  1. +page.svelte mounts immediately.
  2. liveQuery runs against an empty IndexedDB.
  3. API data arrives later and writes to IndexedDB.
  4. Failure: Svelte 5 + Dexie liveQuery may not automatically detect this first "cold start" update without a manual refresh.

Ensure the data is in IndexedDB before the component mounts.

  1. In +page.ts, await the API load function.
  2. In +page.svelte, the liveQuery will see the data immediately upon mount.

Example (+page.ts):

export async function load({ params }) {
    // Blocking await ensures IDB is populated
    await journals_func.load_ae_obj_id__journal({
        journal_id: params.journal_id,
        try_cache: true
    });
    return {};
}

The "Hydrate & Subscribe" Pattern (ADVANCED)

If you must use non-blocking loads, you must pass the initial data to the component to "hydrate" the state before the subscription takes over.

  1. In +page.ts, await the load and return the object.
  2. In +page.svelte, use the returned object as a fallback or initial state.

Example (+page.svelte):

<script>
    let { data } = $props();
    let lq__obj = $derived(liveQuery(async () => db.table.get(id)));
</script>

<!-- Use fallback to handle the gap before liveQuery emits -->
{#if $lq__obj || data.initial_obj}
    <View object={$lq__obj ?? data.initial_obj} />
{/if}

Svelte 5 Binding Pitfalls

1. props_invalid_value (The "Expression Binding" Error)

Svelte 5's bind: directive is more restrictive than previous versions. You can only bind to a simple Identifier or MemberExpression.

Invalid Pattern (Causes Compile Error): Attempting to normalize a value inside the binding will fail.

<!-- Error: Can only bind to an Identifier or MemberExpression -->
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id || null} />

Correct Pattern: Ensure the source value is already normalized before binding, or use a reactive effect to handle the fallback.

// Normalize in an effect or derivation
$effect(() => {
    if ($events_slct.event_session_id === undefined) {
        $events_slct.event_session_id = null;
    }
});
<!-- Bind directly to the normalized property -->
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id} />

Safe Data Processing for IndexedDB Sorting

When preparing data for IndexedDB, especially when creating composite sort keys, it is critical to handle null or undefined values safely to prevent runtime crashes that can interrupt the data synchronization process.

1. Safe String Padding

Attempting to call .toString() or .padStart() on a null or undefined value will throw a TypeError. This is a common pitfall when processing optional fields like sort or group.

Bad Pattern (Crash Risk):

// Crashes if obj.sort is null or undefined
obj.tmp_sort_1 = `${obj.sort.toString().padStart(3, '0')}`;
obj.tmp_sort_2 = `${obj.sort?.toString().padStart(3, '0') ?? ''}`; // Still risky if chaining is misunderstood

Good Pattern (Safe):

// Safely handle null/undefined by defaulting to 0 or an empty string BEFORE string manipulation
const sort_val = (obj.sort ?? 0).toString().padStart(3, '0');

2. Correct Sorting with Dexie

Dexie's sortBy() method returns a new array sorted by the specified key. It ignores previous reverse() calls on the collection. To achieve a descending sort, you must sort first and then reverse the resulting array.

Incorrect (Ascending Sort Result):

// .reverse() is ignored by .sortBy()
let results = await db.table.where('id').equals(id).reverse().sortBy('sort_key');

Correct (Descending Sort Result):

// Sort ascending first, then reverse the array
let results = await db.table.where('id').equals(id).sortBy('sort_key');
return results.reverse();

References