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:
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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; } };
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
plugins: [sveltekit(), svelteTesting()],
|
plugins: [sveltekit(), svelteTesting()],
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'src/**/*.svelte.test.ts'],
|
||||||
setupFiles: ['./vitest.setup.ts']
|
setupFiles: ['./vitest.setup.ts']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user