- 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.
349 lines
18 KiB
Markdown
349 lines
18 KiB
Markdown
# 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).
|
|
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:
|
|
|
|
```svelte
|
|
<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:
|
|
|
|
```typescript
|
|
$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):
|
|
```typescript
|
|
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 `await`s 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.
|
|
|
|
- Journals page (stable LQ + search patterns): [src/routes/journals/[journal_id]/+page.svelte](src/routes/journals/[journal_id]/+page.svelte#L51)
|
|
- Journals layout (blocking background loader): [src/routes/journals/[journal_id]/+layout.ts](src/routes/journals/[journal_id]/+layout.ts#L1)
|
|
- Session page with URL capture + initial fallback: [src/routes/events/[event_id]/(pres_mgmt)/session/[session_id]/+page.svelte](src/routes/events/[event_id]/(pres_mgmt)/session/[session_id]/+page.svelte#L41)
|
|
- Presentation management overview (stable derived + search): [src/routes/events/[event_id]/(pres_mgmt)/pres_mgmt/+page.svelte](src/routes/events/[event_id]/(pres_mgmt)/pres_mgmt/+page.svelte#L70)
|
|
- Event settings example (simple observable): [src/routes/events/[event_id]/settings/+page.svelte](src/routes/events/[event_id]/settings/+page.svelte#L51)
|
|
- Badge/detail pages (examples of nested LQ): [src/routes/events/[event_id]/(badges)/badges/+page.svelte](src/routes/events/[event_id]/(badges)/badges/+page.svelte#L66)
|
|
|
|
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](https://svelte.dev/docs/svelte/v5-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](https://dexie.org/docs/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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```html
|
|
<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.
|
|
|
|
### ✅ The "Blocking Loader" Pattern (RECOMMENDED)
|
|
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):**
|
|
```typescript
|
|
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):**
|
|
```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.
|
|
```svelte
|
|
<!-- 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.
|
|
```typescript
|
|
// Normalize in an effect or derivation
|
|
$effect(() => {
|
|
if ($events_slct.event_session_id === undefined) {
|
|
$events_slct.event_session_id = null;
|
|
}
|
|
});
|
|
```
|
|
```svelte
|
|
<!-- 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):**
|
|
```typescript
|
|
// 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):**
|
|
```typescript
|
|
// 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):**
|
|
```typescript
|
|
// .reverse() is ignored by .sortBy()
|
|
let results = await db.table.where('id').equals(id).reverse().sortBy('sort_key');
|
|
```
|
|
|
|
**Correct (Descending Sort Result):**
|
|
```typescript
|
|
// Sort ascending first, then reverse the array
|
|
let results = await db.table.where('id').equals(id).sortBy('sort_key');
|
|
return results.reverse();
|
|
```
|
|
|
|
## References
|
|
* https://dexie.org/llms.txt - Dexie.js and Dexie Cloud — LLM Guide and Documentation Summary |