diff --git a/web/src/lib/utils/useDelayed.svelte.test.ts b/web/src/lib/utils/useDelayed.svelte.test.ts new file mode 100644 index 00000000..90eea419 --- /dev/null +++ b/web/src/lib/utils/useDelayed.svelte.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, vi } from 'vitest'; +import { flushSync } from 'svelte'; +import { useDelayed } from './useDelayed.svelte'; + +describe('useDelayed', () => { + test('value stays false until delay elapses while source is true', () => { + vi.useFakeTimers(); + try { + let source = $state(false); + let delayed!: { readonly value: boolean }; + const cleanup = $effect.root(() => { + delayed = useDelayed(() => source, 100); + }); + + expect(delayed.value).toBe(false); + + source = true; + flushSync(); + expect(delayed.value).toBe(false); + + vi.advanceTimersByTime(50); + expect(delayed.value).toBe(false); + + vi.advanceTimersByTime(60); + expect(delayed.value).toBe(true); + + cleanup(); + } finally { + vi.useRealTimers(); + } + }); + + test('source flipping false before delay cancels the pending timeout', () => { + vi.useFakeTimers(); + try { + let source = $state(false); + let delayed!: { readonly value: boolean }; + const cleanup = $effect.root(() => { + delayed = useDelayed(() => source, 100); + }); + + source = true; + flushSync(); + vi.advanceTimersByTime(40); + source = false; + flushSync(); + vi.advanceTimersByTime(200); + expect(delayed.value).toBe(false); + + cleanup(); + } finally { + vi.useRealTimers(); + } + }); + + test('value resets to false immediately when source becomes false', () => { + vi.useFakeTimers(); + try { + let source = $state(true); + let delayed!: { readonly value: boolean }; + const cleanup = $effect.root(() => { + delayed = useDelayed(() => source, 100); + }); + + vi.advanceTimersByTime(200); + expect(delayed.value).toBe(true); + + source = false; + flushSync(); + expect(delayed.value).toBe(false); + + cleanup(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/web/src/lib/utils/useDelayed.svelte.ts b/web/src/lib/utils/useDelayed.svelte.ts new file mode 100644 index 00000000..b688275a --- /dev/null +++ b/web/src/lib/utils/useDelayed.svelte.ts @@ -0,0 +1,46 @@ +// Returns a rune-wrapped boolean that mirrors the source getter, but only +// flips to true after the source has been true for delayMs continuously. +// Flips back to false immediately when the source becomes false. +// +// Used to suppress flash-of-skeleton on cache-hit navigation: skeletons +// only render for loads that take longer than ~100ms. +export function useDelayed( + source: () => boolean, + delayMs = 100 +): { readonly value: boolean } { + let delayed = $state(false); + let timeout: ReturnType | null = null; + + // Schedule the initial timeout synchronously so tests that advance fake + // timers before the first reactive flush still see the correct value. + if (source()) { + timeout = setTimeout(() => { + delayed = true; + timeout = null; + }, delayMs); + } + + $effect(() => { + const v = source(); + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (v) { + timeout = setTimeout(() => { + delayed = true; + timeout = null; + }, delayMs); + } else { + delayed = false; + } + return () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + }; + }); + + return { get value() { return delayed; } }; +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts index a6bc5c75..291c6a92 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [sveltekit(), svelteTesting()], test: { environment: 'jsdom', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'src/**/*.svelte.test.ts'], setupFiles: ['./vitest.setup.ts'] } });