feat(web): add useDelayed rune helper

Boolean mirror that flips true only after the source stays true for
delayMs. Used to hide the skeleton on sub-100ms cache hits so
back-navigation feels instant.
This commit is contained in:
2026-04-23 17:23:23 -04:00
parent 828273a78f
commit 6ca877b3ad
3 changed files with 124 additions and 1 deletions
@@ -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();
}
});
});
+46
View File
@@ -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<typeof setTimeout> | 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; } };
}
+1 -1
View File
@@ -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']
}
});