chore: refactor svelte reactivity (#24072)

This commit is contained in:
Daniel Dietzler
2025-11-25 00:57:46 +01:00
committed by GitHub
parent 7694b342ed
commit 8755cd59fd
17 changed files with 105 additions and 128 deletions

View File

@@ -62,10 +62,7 @@ export const setupTimelineMockApiRoutes = async (
return route.continue();
});
await context.route('**/api/assets/**', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match) {
await context.route('**/api/assets/*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
@@ -75,37 +72,50 @@ export const setupTimelineMockApiRoutes = async (
contentType: 'application/json',
json: asset,
});
});
await context.route('**/api/assets/*/ocr', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
}
if (match.groups?.size === 'preview') {
if (match.groups.size === 'preview') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
body: await randomPreview(
match.groups?.assetId,
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
if (match.groups?.size === 'thumbnail') {
if (match.groups.size === 'thumbnail') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail(
match.groups?.assetId,
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
return route.continue();
});
await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern);

View File

@@ -52,7 +52,7 @@
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
let chatHeight: number = $state(0);
let divHeight: number = $state(0);
let divHeight = $derived(innerHeight - activityHeight);
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
@@ -96,11 +96,7 @@
}
isSendingMessage = false;
};
$effect(() => {
if (innerHeight && activityHeight) {
divHeight = innerHeight - activityHeight;
}
});
$effect(() => {
if (assetId && previousAssetId != assetId) {
previousAssetId = assetId;

View File

@@ -35,15 +35,13 @@
});
};
let albumNameArray: string[] = $state(['', '', '']);
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
// It is used to highlight the search query in the album name
$effect(() => {
const albumNameArray: string[] = $derived.by(() => {
let { albumName } = album;
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
let findLength = searchQuery.length;
albumNameArray = [
return [
albumName.slice(0, findIndex),
albumName.slice(findIndex, findIndex + findLength),
albumName.slice(findIndex + findLength),

View File

@@ -395,13 +395,11 @@
}
});
let currentAssetId = $derived(asset.id);
// primarily, this is reactive on `asset`
$effect(() => {
if (currentAssetId) {
untrack(() => handlePromiseError(handleGetAllAlbums()));
handlePromiseError(handleGetAllAlbums());
ocrManager.clear();
handlePromiseError(ocrManager.getAssetOcr(currentAssetId));
}
handlePromiseError(ocrManager.getAssetOcr(asset.id));
});
</script>

View File

@@ -171,7 +171,6 @@
$effect(() => {
if (assetFileUrl) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
}
});

View File

@@ -43,7 +43,9 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let assetFileUrl = $derived(
playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -53,11 +55,9 @@
});
$effect(() => {
assetFileUrl = playOriginalVideo
? getAssetOriginalUrl({ id: assetId, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
videoPlayer.load();
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
videoPlayer?.load();
}
});

View File

@@ -35,7 +35,6 @@
$effect(() => {
if (assetFileUrl) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
}
});

View File

@@ -9,7 +9,6 @@
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
import { Icon } from '@immich/ui';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
interface Props {
places?: AssetResponseDto[];
@@ -70,39 +69,27 @@
},
};
let filteredPlaces: AssetResponseDto[] = $state([]);
let groupedPlaces: PlacesGroup[] = $state([]);
let placesGroupOption: string = $state(PlacesGroupBy.None);
let hasPlaces = $derived(places.length > 0);
// Step 1: Filter using the given search query.
run(() => {
if (searchQuery) {
const filteredPlaces = $derived.by(() => {
const searchQueryNormalized = normalizeSearchString(searchQuery);
filteredPlaces = places.filter((place) => {
return normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized);
return searchQueryNormalized
? places.filter((place) => normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized))
: places;
});
} else {
filteredPlaces = places;
}
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
$effect(() => {
searchResultCount = filteredPlaces.length;
});
// Step 2: Group places.
run(() => {
placesGroupOption = getSelectedPlacesGroupOption(userSettings);
const groupFunc = groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None];
groupedPlaces = groupFunc(filteredPlaces);
$effect(() => {
placesGroupIds = groupedPlaces.map(({ id }) => id);
});
</script>
{#if hasPlaces}
{#if places.length > 0}
<!-- Album Cards -->
{#if placesGroupOption === PlacesGroupBy.None}
<PlacesCardGroup places={groupedPlaces[0].places} />

View File

@@ -27,7 +27,7 @@
let { asset = undefined, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
let searchWord: string = $state('');
let latestSearchTimeout: number;
let showLoadingSpinner = $state(false);
@@ -52,9 +52,6 @@
});
$effect(() => {
if (places) {
suggestedPlaces = places.slice(0, 5);
}
if (searchWord === '') {
suggestedPlaces = [];
}

View File

@@ -33,37 +33,36 @@
children,
}: Props = $props();
let left: number = $state(0);
let top: number = $state(0);
const swap = (direction: string) => (direction === 'left' ? 'right' : 'left');
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number = $state(0);
let isTransitioned = $state(false);
$effect(() => {
if (menuElement) {
let layoutDirection = direction;
if (languageManager.rtl) {
layoutDirection = direction === 'left' ? 'right' : 'left';
const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction);
const position = $derived.by(() => {
if (!menuElement) {
return { left: 0, top: 0 };
}
const rect = menuElement.getBoundingClientRect();
const directionWidth = layoutDirection === 'left' ? rect.width : 0;
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
}
const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
return { left, top };
});
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number = $state(0);
let isTransitioned = $state(false);
</script>
<div
bind:clientHeight={height}
class="fixed min-w-50 w-max max-w-75 overflow-hidden rounded-lg shadow-lg z-1"
style:left="{left}px"
style:top="{top}px"
style:left="{position.left}px"
style:top="{position.top}px"
transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
onintroend={() => {

View File

@@ -64,10 +64,11 @@
}
}
let makeFilter = $derived(filters.make);
let modelFilter = $derived(filters.model);
let lensModelFilter = $derived(filters.lensModel);
const makeFilter = $derived(filters.make);
const modelFilter = $derived(filters.model);
const lensModelFilter = $derived(filters.lensModel);
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => {
handlePromiseError(updateMakes());
});

View File

@@ -7,11 +7,10 @@
</script>
<script lang="ts">
import { run } from 'svelte/legacy';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -66,15 +65,12 @@
}
let countryFilter = $derived(filters.country);
let stateFilter = $derived(filters.state);
run(() => {
handlePromiseError(updateCountries());
});
run(() => {
handlePromiseError(updateStates(countryFilter));
});
run(() => {
handlePromiseError(updateCities(countryFilter, stateFilter));
});
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => handlePromiseError(updateStates(countryFilter)));
$effect(() => handlePromiseError(updateCities(countryFilter, stateFilter)));
onMount(() => updateCountries());
</script>
<div id="location-selection">

View File

@@ -19,21 +19,23 @@ export type OcrBoundingBox = {
class OcrManager {
#data = $state<OcrBoundingBox[]>([]);
showOverlay = $state(false);
hasOcrData = $state(false);
#hasOcrData = $derived(this.#data.length > 0);
get data() {
return this.#data;
}
get hasOcrData() {
return this.#hasOcrData;
}
async getAssetOcr(id: string) {
this.#data = await getAssetOcr({ id });
this.hasOcrData = this.#data.length > 0;
}
clear() {
this.#data = [];
this.showOverlay = false;
this.hasOcrData = false;
}
toggleOcrBoundingBox() {

View File

@@ -1,8 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { page } from '$app/state';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Snippet } from 'svelte';
@@ -15,13 +13,13 @@
// $page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
run(() => {
if ($page.data.asset) {
setAsset($page.data.asset);
$effect.pre(() => {
if (page.data.asset) {
setAsset(page.data.asset);
} else {
$showAssetViewer = false;
}
const asset = $page.url.searchParams.get('at');
const asset = page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
});
</script>

View File

@@ -44,7 +44,7 @@
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -71,11 +71,10 @@
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
$effect(() => {
// we want this to *only* be reactive on `terms`
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
terms;
setTimeout(() => {
handlePromiseError(onSearchQueryUpdate());
});
untrack(() => handlePromiseError(onSearchQueryUpdate()));
});
const onEscape = () => {

View File

@@ -22,7 +22,6 @@
import { modalManager, setTranslations } from '@immich/ui';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
import '../app.css';
interface Props {
@@ -69,7 +68,8 @@
afterNavigate(() => {
showNavigationLoadingBar = false;
});
run(() => {
$effect.pre(() => {
if ($user || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
openWebsocketConnection();
} else {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import OnboardingBackup from '$lib/components/onboarding-page/onboarding-backup.svelte';
import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
@@ -95,7 +95,11 @@
},
]);
let index = $state(0);
const index = $derived.by(() => {
const stepState = page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
return temporaryIndex === -1 ? 0 : temporaryIndex;
});
let userRole = $derived(
$user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER,
);
@@ -114,12 +118,6 @@
);
};
$effect(() => {
const stepState = $page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
index = temporaryIndex === -1 ? 0 : temporaryIndex;
});
const previousStepIndex = $derived(
onboardingSteps.findLastIndex((step, i) => shouldRunStep(step.role, userRole) && i < index),
);