Compare commits

..

6 Commits

8 changed files with 370 additions and 82 deletions
@@ -561,4 +561,67 @@ describe('FontStore', () => {
store.destroy(); store.destroy();
}); });
}); });
describe('fetchAllPagesTo', () => {
beforeEach(() => {
fetch.mockReset();
queryClient.clear();
});
it('fetches all missing pages in parallel up to targetIndex', async () => {
// First page already loaded (offset 0, limit 10, total 50)
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
expect(store.fonts).toHaveLength(10);
// Mock remaining pages
for (let offset = 10; offset < 50; offset += 10) {
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 50, limit: 10, offset }),
);
}
await store.fetchAllPagesTo(40);
flushSync();
expect(store.fonts).toHaveLength(50);
});
it('skips pages that fail and still merges successful ones', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 30, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
// offset=10 fails, offset=20 succeeds
fetch.mockRejectedValueOnce(new Error('network error'));
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 20 }),
);
await store.fetchAllPagesTo(25);
flushSync();
// Page at offset=20 merged, page at offset=10 missing — 20 total
expect(store.fonts).toHaveLength(20);
});
it('is a no-op when target is within already-loaded data', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
const callsBefore = fetch.mock.calls.length;
await store.fetchAllPagesTo(5);
expect(fetch.mock.calls.length).toBe(callsBefore);
});
});
}); });
@@ -242,6 +242,80 @@ export class FontStore {
async nextPage(): Promise<void> { async nextPage(): Promise<void> {
await this.#observer.fetchNextPage(); await this.#observer.fetchNextPage();
} }
#isCatchingUp = false;
#inFlightOffsets = new Set<number>();
/**
* Fetch all pages between the current loaded count and targetIndex in parallel.
* Pages are merged into the cache as they arrive (sorted by offset).
* Failed pages are silently skipped — normal scroll will re-fetch them on demand.
*/
async fetchAllPagesTo(targetIndex: number): Promise<void> {
if (this.#isCatchingUp) {
return;
}
const pageSize = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
// Collect offsets for all missing and not-in-flight pages
const missingOffsets: number[] = [];
for (let offset = 0; offset <= targetIndex; offset += pageSize) {
if (!loadedOffsets.has(offset) && !this.#inFlightOffsets.has(offset)) {
missingOffsets.push(offset);
}
}
if (missingOffsets.length === 0) {
return;
}
this.#isCatchingUp = true;
// Sorted merge buffer — flush in offset order as pages arrive
const buffer = new Map<number, ProxyFontsResponse>();
const failed = new Set<number>();
let nextFlushOffset = (existing.pageParams.at(-1)?.offset ?? -pageSize) + pageSize;
const flush = () => {
while (buffer.has(nextFlushOffset) || failed.has(nextFlushOffset)) {
if (buffer.has(nextFlushOffset)) {
this.#appendPageToCache(buffer.get(nextFlushOffset)!);
buffer.delete(nextFlushOffset);
}
failed.delete(nextFlushOffset);
nextFlushOffset += pageSize;
}
};
try {
await Promise.allSettled(
missingOffsets.map(async offset => {
this.#inFlightOffsets.add(offset);
try {
const page = await this.fetchPage({ ...this.#params, offset });
buffer.set(offset, page);
} catch {
failed.add(offset);
} finally {
this.#inFlightOffsets.delete(offset);
}
flush();
}),
);
} finally {
this.#isCatchingUp = false;
}
}
/** /**
* Backward pagination (no-op: infinite scroll accumulates forward only) * Backward pagination (no-op: infinite scroll accumulates forward only)
*/ */
@@ -289,6 +363,34 @@ export class FontStore {
return this.fonts.filter(f => f.category === 'monospace'); return this.fonts.filter(f => f.category === 'monospace');
} }
/**
* Merge a single page into the InfiniteQuery cache in offset order.
* Called by fetchAllPagesTo as each parallel fetch resolves.
*/
#appendPageToCache(page: ProxyFontsResponse): void {
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
// Guard against duplicates
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
if (loadedOffsets.has(page.offset)) {
return;
}
const allPages = [...existing.pages, page].sort((a, b) => a.offset - b.offset);
const allParams = [...existing.pageParams, { offset: page.offset }].sort(
(a, b) => a.offset - b.offset,
);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key, {
pages: allPages,
pageParams: allParams,
});
}
private buildQueryKey(params: FontStoreParams): readonly unknown[] { private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {}; const filtered: Record<string, any> = {};
@@ -1,14 +1,11 @@
<!-- <!--
Component: FontApplicator Component: FontApplicator
Loads fonts from fontshare with link tag Applies a font to its children once the font file is loaded.
- Loads font only if it's not already applied Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
- Reacts to font load status to show/hide content
- Adds smooth transition when font appears
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import { import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
type UnifiedFont, type UnifiedFont,
@@ -33,6 +30,11 @@ interface Props {
* Content snippet * Content snippet
*/ */
children?: Snippet; children?: Snippet;
/**
* Shown while the font file is loading.
* When omitted, children render in system font until ready.
*/
skeleton?: Snippet;
} }
let { let {
@@ -40,6 +42,7 @@ let {
weight = DEFAULT_FONT_WEIGHT, weight = DEFAULT_FONT_WEIGHT,
className, className,
children, children,
skeleton,
}: Props = $props(); }: Props = $props();
const status = $derived( const status = $derived(
@@ -50,30 +53,16 @@ const status = $derived(
), ),
); );
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
const shouldReveal = $derived(status === 'loaded' || status === 'error'); const shouldReveal = $derived(status === 'loaded' || status === 'error');
const transitionClasses = $derived(
prefersReducedMotion.current
? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
);
</script> </script>
{#if !shouldReveal && skeleton}
{@render skeleton()}
{:else}
<div <div
style:font-family={shouldReveal style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
? `'${font.name}'` class={clsx(className)}
: 'system-ui, -apple-system, sans-serif'}
class={clsx(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0',
className,
)}
> >
{@render children?.()} {@render children?.()}
</div> </div>
{/if}
@@ -4,6 +4,7 @@
- Handles font registration with the manager - Handles font registration with the manager
--> -->
<script lang="ts"> <script lang="ts">
import { debounce } from '$shared/lib/utils';
import { import {
Skeleton, Skeleton,
VirtualList, VirtualList,
@@ -54,6 +55,10 @@ const isLoading = $derived(
); );
let visibleFonts = $state<UnifiedFont[]>([]); let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
function handleInternalVisibleChange(items: UnifiedFont[]) { function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items; visibleFonts = items;
@@ -61,8 +66,32 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
onVisibleItemsChange?.(items); onVisibleItemsChange?.(items);
} }
/**
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
* font files for thousands of intermediate fonts.
*/
async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontStore.pagination.hasMore) {
return;
}
isCatchingUp = true;
try {
await fontStore.fetchAllPagesTo(targetIndex);
} finally {
isCatchingUp = false;
}
}
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
appliedFontsManager.touch(configs);
}, 150);
// Re-touch whenever visible set or weight changes — fixes weight-change gap // Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => { $effect(() => {
if (isCatchingUp) {
return;
}
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => { const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
const url = getFontUrl(item, weight); const url = getFontUrl(item, weight);
if (!url) { if (!url) {
@@ -71,7 +100,7 @@ $effect(() => {
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }]; return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
}); });
if (configs.length > 0) { if (configs.length > 0) {
appliedFontsManager.touch(configs); debouncedTouch(configs);
} }
}); });
@@ -113,17 +142,19 @@ function loadMore() {
function handleNearBottom(_lastVisibleIndex: number) { function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontStore.pagination; const { hasMore } = fontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items // VirtualList already checks if we're near the bottom of loaded items.
if (hasMore && !fontStore.isFetching) { // Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
loadMore(); loadMore();
} }
} }
</script> </script>
<div class="relative w-full h-full"> <div class="relative w-full h-full">
{#if skeleton && isLoading && fontStore.fonts.length === 0} {#if showInitialSkeleton && skeleton}
<!-- Show skeleton only on initial load when no fonts are loaded yet --> <!-- Show skeleton only on initial load when no fonts are loaded yet -->
<div transition:fade={{ duration: 300 }}> <div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
{@render skeleton()} {@render skeleton()}
</div> </div>
{:else} {:else}
@@ -131,14 +162,20 @@ function handleNearBottom(_lastVisibleIndex: number) {
<VirtualList <VirtualList
items={fontStore.fonts} items={fontStore.fonts}
total={fontStore.pagination.total} total={fontStore.pagination.total}
isLoading={isLoading} isLoading={isLoading || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
onJump={handleJump}
{...rest} {...rest}
> >
{#snippet children(scope)} {#snippet children(scope)}
{@render children(scope)} {@render children(scope)}
{/snippet} {/snippet}
</VirtualList> </VirtualList>
{#if showCatchupSkeleton && skeleton}
<div class="absolute inset-0 overflow-hidden" transition:fade={{ duration: 150 }}>
{@render skeleton()}
</div>
{/if}
{/if} {/if}
</div> </div>
@@ -0,0 +1,13 @@
/**
* Generates a consistent but varied width for skeleton placeholders.
* Uses a predefined sequence to ensure stability between renders.
*
* @param index - Index of the item in a list to pick a width from the sequence
* @param multiplier - Multiplier to apply to the base sequence values (default: 4)
* @returns CSS width value (e.g., "128px")
*/
export function getSkeletonWidth(index: number, multiplier = 4): string {
const sequence = [32, 48, 40, 56, 36, 44, 52, 38, 46, 42, 34, 50];
const base = sequence[index % sequence.length];
return `${base * multiplier}px`;
}
+1
View File
@@ -17,6 +17,7 @@ export {
export { clampNumber } from './clampNumber/clampNumber'; export { clampNumber } from './clampNumber/clampNumber';
export { debounce } from './debounce/debounce'; export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll'; export { smoothScroll } from './smoothScroll/smoothScroll';
export { splitArray } from './splitArray/splitArray'; export { splitArray } from './splitArray/splitArray';
@@ -62,6 +62,10 @@ interface Props extends
* Near bottom callback * Near bottom callback
*/ */
onNearBottom?: (lastVisibleIndex: number) => void; onNearBottom?: (lastVisibleIndex: number) => void;
/**
* Fires when scroll position exceeds loaded content — user jumped beyond data
*/
onJump?: (targetIndex: number) => void;
/** /**
* Item render snippet * Item render snippet
*/ */
@@ -95,6 +99,7 @@ let {
class: className, class: className,
onVisibleItemsChange, onVisibleItemsChange,
onNearBottom, onNearBottom,
onJump,
children, children,
useWindowScroll = false, useWindowScroll = false,
isLoading = false, isLoading = false,
@@ -170,6 +175,10 @@ const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex); onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms throttle }, 200); // 200ms throttle
const throttledOnJump = throttle((targetIndex: number) => {
onJump?.(targetIndex);
}, 200);
// Calculate top/bottom padding for spacer elements // Calculate top/bottom padding for spacer elements
// In CSS Grid, gap creates space BETWEEN elements. // In CSS Grid, gap creates space BETWEEN elements.
// The top spacer should place the first row at its virtual offset. // The top spacer should place the first row at its virtual offset.
@@ -227,6 +236,26 @@ $effect(() => {
} }
} }
}); });
$effect(() => {
// Fire onJump when scroll is beyond the loaded content boundary.
// Target index estimates which item the user scrolled to.
if (!onJump || !virtualizer.containerHeight || virtualizer.scrollOffset <= 0) {
return;
}
const isAhead = virtualizer.scrollOffset > virtualizer.totalSize;
if (!isAhead) {
return;
}
const estimatedItemHeight = typeof itemHeight === 'number' ? itemHeight : 80;
// Include visible rows + overscan so the bottom of the viewport is fully covered
const topItemIndex = Math.floor(virtualizer.scrollOffset / estimatedItemHeight) * columns;
const visibleRows = Math.ceil(virtualizer.containerHeight / estimatedItemHeight);
const targetIndex = topItemIndex + (visibleRows + overscan) * columns;
throttledOnJump(targetIndex);
});
</script> </script>
{#snippet content()} {#snippet content()}
@@ -9,13 +9,17 @@ import {
FontVirtualList, FontVirtualList,
type UnifiedFont, type UnifiedFont,
VIRTUAL_INDEX_NOT_LOADED, VIRTUAL_INDEX_NOT_LOADED,
appliedFontsManager,
fontStore, fontStore,
} from '$entities/Font'; } from '$entities/Font';
import { getSkeletonWidth } from '$shared/lib/utils';
import { import {
Button, Button,
Label, Label,
Skeleton,
} from '$shared/ui'; } from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot'; import DotIcon from '@lucide/svelte/icons/dot';
import { fade } from 'svelte/transition';
import { import {
createDotCrossfade, createDotCrossfade,
getDotTransitionParams, getDotTransitionParams,
@@ -69,6 +73,21 @@ function handleSelect(font: UnifiedFont) {
comparisonStore.fontB = font; comparisonStore.fontB = font;
} }
} }
/**
* Returns true once the font file is loaded (or errored) and safe to render.
* Called inside the template — Svelte 5 tracks the $state reads inside
* appliedFontsManager.getFontStatus(), so each row re-renders reactively
* when its file arrives.
*/
function isFontReady(font: UnifiedFont): boolean {
const status = appliedFontsManager.getFontStatus(
font.id,
DEFAULT_FONT_WEIGHT,
font.features?.isVariable,
);
return status === 'loaded' || status === 'error';
}
</script> </script>
<div class="flex-1 min-h-0 h-full"> <div class="flex-1 min-h-0 h-full">
@@ -81,22 +100,54 @@ function handleSelect(font: UnifiedFont) {
<FontVirtualList <FontVirtualList
data-font-list data-font-list
weight={DEFAULT_FONT_WEIGHT} weight={DEFAULT_FONT_WEIGHT}
itemHeight={45} itemHeight={44}
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4" class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
> >
{#snippet skeleton()}
<div class="py-2.5 md:py-3 px-7">
{#each { length: 50 } as _, index (index)}
<div class="w-full px-3 py-3 flex items-center justify-between">
<div class="flex-1 flex items-center gap-3">
<Skeleton
class="h-4 w-32 bg-neutral-200/70 dark:bg-neutral-800/70"
style="width: {getSkeletonWidth(index)}"
/>
</div>
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
</div>
{/each}
</div>
{/snippet}
{#snippet children({ item: font, index })} {#snippet children({ item: font, index })}
<div class="relative h-[44px] w-full">
{#if !isFontReady(font)}
<div
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
transition:fade={{ duration: 300 }}
>
<Skeleton
class="h-4 bg-neutral-200/70 dark:bg-neutral-800/70"
style="width: {getSkeletonWidth(index)}"
/>
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
</div>
{:else}
{@const isSelectedA = font.id === comparisonStore.fontA?.id} {@const isSelectedA = font.id === comparisonStore.fontA?.id}
{@const isSelectedB = font.id === comparisonStore.fontB?.id} {@const isSelectedB = font.id === comparisonStore.fontB?.id}
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)} {@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
<div transition:fade={{ duration: 300 }} class="h-full">
<Button <Button
variant="tertiary" variant="tertiary"
{active} {active}
onclick={() => handleSelect(font)} onclick={() => handleSelect(font)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm" class="w-full h-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
iconPosition="right" iconPosition="right"
> >
<FontApplicator {font}>{font.name}</FontApplicator> <FontApplicator {font}>
{font.name}
</FontApplicator>
{#snippet icon()} {#snippet icon()}
{#if active} {#if active}
@@ -133,6 +184,9 @@ function handleSelect(font: UnifiedFont) {
{/if} {/if}
{/snippet} {/snippet}
</Button> </Button>
</div>
{/if}
</div>
{/snippet} {/snippet}
</FontVirtualList> </FontVirtualList>
</div> </div>