From 3b10a1f47fd5838fe3b94c19673a52610b88cf1e Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 1 Mar 2026 14:20:08 -0800 Subject: perf: optimise list hot paths and shared timers --- src/lib/List/Anime/CleanAnimeList.svelte | 83 ++++++++---- src/lib/List/Anime/DueAnimeList.svelte | 47 +++++-- src/lib/List/CleanGrid.svelte | 4 +- src/lib/List/CleanList.svelte | 4 +- src/lib/List/Manga/MangaListTemplate.svelte | 45 +++++-- src/lib/Media/Anime/Airing/AiringTime.svelte | 180 +++++++++++++-------------- src/lib/Utility/html.ts | 109 +++++++++++----- 7 files changed, 296 insertions(+), 176 deletions(-) (limited to 'src/lib') diff --git a/src/lib/List/Anime/CleanAnimeList.svelte b/src/lib/List/Anime/CleanAnimeList.svelte index 561b3b1d..aee035a4 100644 --- a/src/lib/List/Anime/CleanAnimeList.svelte +++ b/src/lib/List/Anime/CleanAnimeList.svelte @@ -37,7 +37,8 @@ export let limit: number | undefined = undefined; let showRoulette = false; - let keyCacher: ReturnType; + let airingRefreshTimeout: ReturnType | undefined; + let scheduledAiringAt: number | null = null; let totalEpisodeDueCount = media .map((anime) => { if ($settings.displayTotalEpisodes && !$settings.displayTotalDueEpisodes) return 1; @@ -81,35 +82,61 @@ ? media : media.filter((m) => m.mediaListEntry?.customLists?.[selectedList]); - onMount(async () => { - if (dummy) return; + const clearAiringRefreshTimeout = () => { + if (airingRefreshTimeout) clearTimeout(airingRefreshTimeout); + + airingRefreshTimeout = undefined; + scheduledAiringAt = null; + }; + + const scheduleAiringRefresh = () => { + if (!browser) return; + + if (dummy || media.length === 0) { + clearAiringRefreshTimeout(); + + return; + } + + const nextAiringAt = media.reduce((closest, currentMedia) => { + if (currentMedia.status !== 'RELEASING' && currentMedia.status !== 'NOT_YET_RELEASED') + return closest; + + const airingAt = currentMedia.nextAiringEpisode?.airingAt; + + if (!airingAt) return closest; + if (closest === null) return airingAt; + + return airingAt < closest ? airingAt : closest; + }, null); - keyCacher = setInterval( + if (!nextAiringAt) { + clearAiringRefreshTimeout(); + + return; + } + + if (airingRefreshTimeout && scheduledAiringAt === nextAiringAt) return; + + clearAiringRefreshTimeout(); + scheduledAiringAt = nextAiringAt; + airingRefreshTimeout = setTimeout( () => { - media = media; + const now = Date.now() / 1000; - if ( - media.some( - (m) => m.nextAiringEpisode?.airingAt && m.nextAiringEpisode.airingAt < Date.now() / 1000 - ) - ) + if (media.some((m) => m.nextAiringEpisode?.airingAt && m.nextAiringEpisode.airingAt < now)) animeLists = cleanCache(user, $identity); + + scheduleAiringRefresh(); }, - (() => { - const airingAt = media - .filter( - (m) => - (m.status === 'RELEASING' || m.status === 'NOT_YET_RELEASED') && - m.nextAiringEpisode?.airingAt - ) - .find((m) => m.nextAiringEpisode?.airingAt)?.nextAiringEpisode?.airingAt; - const untilAiring = airingAt - ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 - : undefined; - - return untilAiring ? (untilAiring < 0 ? 1000 : untilAiring) : 1000; - })() + Math.max(1000, nextAiringAt * 1000 - Date.now() + 250) ); + }; + + onMount(async () => { + if (dummy) return; + + scheduleAiringRefresh(); if (browser) await localforage.setItem( @@ -120,7 +147,13 @@ ); }); - onDestroy(() => clearInterval(keyCacher)); + $: if (browser && !dummy) { + media; + + scheduleAiringRefresh(); + } + + onDestroy(() => clearAiringRefreshTimeout()); const increment = (anime: Media, progress: number) => { if (!dummy && pendingUpdate !== anime.id) { diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte index 2db8da65..0c1e128a 100644 --- a/src/lib/List/Anime/DueAnimeList.svelte +++ b/src/lib/List/Anime/DueAnimeList.svelte @@ -16,27 +16,48 @@ let animeLists: Promise; let startTime: number; let endTime: number; - - const keyCacher = setInterval( - () => { - startTime = performance.now(); - endTime = -1; - animeLists = mediaListCollection(user, $identity, Type.Anime, $anime, $lastPruneTimes.anime, { - forcePrune: true, - addNotification - }); - }, - $settings.cacheMinutes * 1000 * 60 - ); + let keyCacher: ReturnType | undefined; + let keyCacheMinutes = -1; + + const restartKeyCacher = (cacheMinutes: number) => { + if (keyCacher) clearInterval(keyCacher); + + keyCacheMinutes = cacheMinutes; + keyCacher = setInterval( + () => { + startTime = performance.now(); + endTime = -1; + animeLists = mediaListCollection( + user, + $identity, + Type.Anime, + $anime, + $lastPruneTimes.anime, + { + forcePrune: true, + addNotification + } + ); + }, + cacheMinutes * 1000 * 60 + ); + }; onMount(async () => { + restartKeyCacher($settings.cacheMinutes); + startTime = performance.now(); animeLists = mediaListCollection(user, $identity, Type.Anime, $anime, $lastPruneTimes.anime, { addNotification }); }); - onDestroy(() => clearInterval(keyCacher)); + $: if (keyCacher && keyCacheMinutes !== $settings.cacheMinutes) + restartKeyCacher($settings.cacheMinutes); + + onDestroy(() => { + if (keyCacher) clearInterval(keyCacher); + }); const cleanMedia = ( anime: Media[], diff --git a/src/lib/List/CleanGrid.svelte b/src/lib/List/CleanGrid.svelte index 1e6f5a12..4f628c3c 100644 --- a/src/lib/List/CleanGrid.svelte +++ b/src/lib/List/CleanGrid.svelte @@ -17,7 +17,7 @@ let uniqueID = new Date().getTime(); - $: sortedMedia = reverseSort ? media.reverse() : media; + $: sortedMedia = reverseSort ? [...media].reverse() : media; $: processedMedia = limit !== undefined ? sortedMedia.slice(0, limit) : sortedMedia; @@ -25,7 +25,7 @@ class="covers" style={`grid-template-columns: repeat(auto-fill, minmax(${$settings.displayCoverWidth}px, 1fr))`} > - {#each processedMedia as title, index} + {#each processedMedia as title, index (title.id)} {@const progress = (title.mediaListEntry || { progress: 0 }).progress} {@const isAboveFold = index < 6} diff --git a/src/lib/List/CleanList.svelte b/src/lib/List/CleanList.svelte index 63656ab3..bf8c44ff 100644 --- a/src/lib/List/CleanList.svelte +++ b/src/lib/List/CleanList.svelte @@ -12,11 +12,11 @@ export let lastUpdatedMedia: number; export let reverseSort = false; - $: processedMedia = reverseSort ? media.reverse() : media; + $: processedMedia = reverseSort ? [...media].reverse() : media;
    - {#each processedMedia as title} + {#each processedMedia as title (title.id)} {@const progress = (title.mediaListEntry || { progress: 0 }).progress} {#if type === 'anime' ? upcoming || notYetReleased || progress !== (title.nextAiringEpisode?.episode || 9999) - 1 : progress !== title.episodes} diff --git a/src/lib/List/Manga/MangaListTemplate.svelte b/src/lib/List/Manga/MangaListTemplate.svelte index c2fc0513..f549496d 100644 --- a/src/lib/List/Manga/MangaListTemplate.svelte +++ b/src/lib/List/Manga/MangaListTemplate.svelte @@ -45,19 +45,35 @@ let rateLimited = false; let forceFlag = false; let lastListSize = 5; - - const keyCacher = setInterval( - () => { - startTime = performance.now(); - endTime = -1; - mangaLists = mediaListCollection(user, $identity, Type.Manga, $manga, $lastPruneTimes.manga, { - addNotification - }); - }, - $settings.cacheMinutes * 1000 * 60 - ); + let keyCacher: ReturnType | undefined; + let keyCacheMinutes = -1; + + const restartKeyCacher = (cacheMinutes: number) => { + if (keyCacher) clearInterval(keyCacher); + + keyCacheMinutes = cacheMinutes; + keyCacher = setInterval( + () => { + startTime = performance.now(); + endTime = -1; + mangaLists = mediaListCollection( + user, + $identity, + Type.Manga, + $manga, + $lastPruneTimes.manga, + { + addNotification + } + ); + }, + cacheMinutes * 1000 * 60 + ); + }; onMount(async () => { + restartKeyCacher(Math.max($settings.cacheMangaMinutes, 5)); + if (browser) { const lastStoredList = (await localforage.getItem( `last${due ? '' : 'Completed'}MangaListLength` @@ -96,7 +112,12 @@ } }); - onDestroy(() => clearInterval(keyCacher)); + $: if (keyCacher && keyCacheMinutes !== Math.max($settings.cacheMangaMinutes, 5)) + restartKeyCacher(Math.max($settings.cacheMangaMinutes, 5)); + + onDestroy(() => { + if (keyCacher) clearInterval(keyCacher); + }); const cleanMedia = async (manga: Media[], displayUnresolved: boolean, force: boolean) => { progress = 0; diff --git a/src/lib/Media/Anime/Airing/AiringTime.svelte b/src/lib/Media/Anime/Airing/AiringTime.svelte index 8cf1cacc..ff888629 100644 --- a/src/lib/Media/Anime/Airing/AiringTime.svelte +++ b/src/lib/Media/Anime/Airing/AiringTime.svelte @@ -1,10 +1,10 @@ {#if upcoming} diff --git a/src/lib/Utility/html.ts b/src/lib/Utility/html.ts index 8d60d3b6..ccdfae8d 100644 --- a/src/lib/Utility/html.ts +++ b/src/lib/Utility/html.ts @@ -4,39 +4,88 @@ import { get } from 'svelte/store'; export const nbsp = (str: string) => str.replace(/ /g, ' '); export const createHeightObserver = (details = true) => { - document.querySelectorAll('.list').forEach((element) => { - if ( - !( - element as unknown as { - dataset: { observed: string }; - } - ).dataset.observed - ) { - new ResizeObserver((entries) => { - entries.forEach((entry) => { - const element = entry.target as HTMLElement; - - if (get(settings).displayLimitListHeight) { - element.style.height = 'auto'; - - const elementBound = element.getBoundingClientRect(); - const height = window.innerHeight - elementBound.top - 2.5 * 16; - - if (elementBound.height > height) element.style.height = `${height}px`; - } - }); - }).observe(element); + const observedElements = new Set(); + const resizeObservers = new Map(); + const detailObservers = new Map(); + + const applyHeightLimit = (target: HTMLElement) => { + if (!get(settings).displayLimitListHeight) { + target.style.height = 'auto'; + + return; + } + + target.style.height = 'auto'; + + const elementBound = target.getBoundingClientRect(); + const height = window.innerHeight - elementBound.top - 2.5 * 16; - if (details) - new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - const element = mutation.target as HTMLDetailsElement; + if (elementBound.height > height) target.style.height = `${height}px`; + }; - if (element.tagName === 'DETAILS' && !element.open) element.style.height = 'auto'; - }); - }).observe(element, { attributes: true }); + const observeElement = (element: HTMLElement) => { + if (element.dataset.observed) return; + + const resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const target = entry.target as HTMLElement; + + applyHeightLimit(target); + }); + }); + + resizeObserver.observe(element); + resizeObservers.set(element, resizeObserver); + + if (details) { + const detailsObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + const target = mutation.target as HTMLDetailsElement; + + if (target.tagName === 'DETAILS' && !target.open) target.style.height = 'auto'; + }); + }); - element.setAttribute('data-observed', 'true'); + detailsObserver.observe(element, { attributes: true }); + detailObservers.set(element, detailsObserver); } + + element.dataset.observed = 'true'; + + observedElements.add(element); + applyHeightLimit(element); + }; + + document.querySelectorAll('.list').forEach(observeElement); + + const mutationObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (!(node instanceof HTMLElement)) return; + + if (node.matches('.list')) observeElement(node); + + node.querySelectorAll('.list').forEach(observeElement); + }); + }); }); + + mutationObserver.observe(document.body, { childList: true, subtree: true }); + + const unsubscribeSettings = settings.subscribe(() => { + observedElements.forEach((element) => applyHeightLimit(element)); + }); + + return () => { + unsubscribeSettings(); + mutationObserver.disconnect(); + resizeObservers.forEach((observer) => observer.disconnect()); + detailObservers.forEach((observer) => observer.disconnect()); + + observedElements.forEach((element) => { + element.style.height = 'auto'; + + delete element.dataset.observed; + }); + }; }; -- cgit v1.2.3