bug: URL-synced inputs drop keystrokes and flicker during fast typing #34

Closed
opened 2026-03-20 16:54:01 +01:00 by marcel · 0 comments
Owner

Problem

Fast typing in URL-bound filter inputs (e.g. the document full-text search on /, date filters on /conversations) causes:

  • Dropped keystrokes — characters typed while a navigation is in-flight are silently lost
  • Flickering — the input value visibly jumps back to a previous value mid-typing

Root Cause

All filter pages follow the same two-sided sync pattern:

// 1. Local $state, seeded from URL on first render
let q = $state(untrack(() => data.filters?.q || ''));

// 2. After every navigation, sync URL back into local state
$effect(() => {
    q = data.filters?.q || '';
    // ...all other filter vars
});

// 3. On user input, push current state to URL via goto()
function triggerSearch() {
    goto(`/?q=${q}`, { keepFocus: true });
}

The race condition:

  1. User types "a"q = "a" → 500 ms debounce fires → goto("/?q=a") → navigation starts
  2. While the navigation is in-flight, user types "b", "c"q = "abc" (local state is correct)
  3. The first navigation completes (for ?q=a) → data.filters.q = "a"$effect fires → q is overwritten back to "a"
  4. The debounce fires again for "abc", launching another navigation — causing another overwrite cycle

Because the $effect is unconditional (it always writes data.filters → state), any in-progress typing gets clobbered as soon as any navigation response arrives.

Affected Pages / Inputs

Page Input Event
/ (documents) Full-text search q oninput + 500 ms debounce
/conversations Date-from / date-to filters onchange (less severe, fires on blur)
/conversations Sort direction toggle onclick (fires on click, not typing)

The text search on / is the most severely affected because it fires on every keystroke.

Expected Behaviour

Characters typed at any speed must all be captured. The input must not jump back or lose text.

Proposed Fix

Option A — Skip $effect sync while the input is focused
Guard the effect with a document.activeElement check, or set a dirty flag while the user is editing and clear it after debounce fires.

Option B — Debounce the $effect sync
Add a short guard (e.g. only sync if q !== q_local) so the effect is a no-op when local state is already ahead of the URL.

Option C — Replace goto + $effect with replaceState
Use SvelteKit's pushState / replaceState (shallow navigation) combined with invalidate() for just the data fetch, instead of a full goto. This decouples URL updates from the reactive data cycle and eliminates the round-trip overwrite.

Option C is the cleanest long-term solution because it removes the bidirectional coupling entirely.

## Problem Fast typing in URL-bound filter inputs (e.g. the document full-text search on `/`, date filters on `/conversations`) causes: - **Dropped keystrokes** — characters typed while a navigation is in-flight are silently lost - **Flickering** — the input value visibly jumps back to a previous value mid-typing ## Root Cause All filter pages follow the same two-sided sync pattern: ```typescript // 1. Local $state, seeded from URL on first render let q = $state(untrack(() => data.filters?.q || '')); // 2. After every navigation, sync URL back into local state $effect(() => { q = data.filters?.q || ''; // ...all other filter vars }); // 3. On user input, push current state to URL via goto() function triggerSearch() { goto(`/?q=${q}`, { keepFocus: true }); } ``` **The race condition:** 1. User types `"a"` → `q = "a"` → 500 ms debounce fires → `goto("/?q=a")` → navigation starts 2. While the navigation is *in-flight*, user types `"b"`, `"c"` → `q = "abc"` (local state is correct) 3. The first navigation completes (for `?q=a`) → `data.filters.q` = `"a"` → `$effect` fires → **`q` is overwritten back to `"a"`** 4. The debounce fires again for `"abc"`, launching another navigation — causing another overwrite cycle Because the `$effect` is unconditional (it always writes `data.filters` → state), any in-progress typing gets clobbered as soon as any navigation response arrives. ## Affected Pages / Inputs | Page | Input | Event | |------|-------|-------| | `/` (documents) | Full-text search `q` | `oninput` + 500 ms debounce | | `/conversations` | Date-from / date-to filters | `onchange` (less severe, fires on blur) | | `/conversations` | Sort direction toggle | `onclick` (fires on click, not typing) | The text search on `/` is the most severely affected because it fires on every keystroke. ## Expected Behaviour Characters typed at any speed must all be captured. The input must not jump back or lose text. ## Proposed Fix **Option A — Skip `$effect` sync while the input is focused** Guard the effect with a `document.activeElement` check, or set a `dirty` flag while the user is editing and clear it after debounce fires. **Option B — Debounce the `$effect` sync** Add a short guard (e.g. only sync if `q !== q_local`) so the effect is a no-op when local state is already ahead of the URL. **Option C — Replace `goto` + `$effect` with `replaceState`** Use SvelteKit's `pushState` / `replaceState` (shallow navigation) combined with `invalidate()` for just the data fetch, instead of a full `goto`. This decouples URL updates from the reactive data cycle and eliminates the round-trip overwrite. Option C is the cleanest long-term solution because it removes the bidirectional coupling entirely.
marcel added the bug label 2026-03-20 19:11:26 +01:00
Sign in to join this conversation.
No Label bug
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#34