Skip to content

Commit

Permalink
feat: image cache invalidation magic [INS-4318] (#8103)
Browse files Browse the repository at this point in the history
Co-authored-by: gatzjames <[email protected]>
  • Loading branch information
ryan-willis and gatzjames authored Oct 18, 2024
1 parent db1e3b2 commit 44752d0
Show file tree
Hide file tree
Showing 6 changed files with 205 additions and 94 deletions.
2 changes: 2 additions & 0 deletions packages/insomnia/src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export const getClientString = () => `${getAppEnvironment()}::${getAppPlatform()
// Global Stuff
export const DEBOUNCE_MILLIS = 100;

export const CDN_INVALIDATION_TTL = 10_000; // 10 seconds

export const STATUS_CODE_PLUGIN_ERROR = -222;
export const LARGE_RESPONSE_MB = 5;
export const HUGE_RESPONSE_MB = 100;
Expand Down
45 changes: 4 additions & 41 deletions packages/insomnia/src/ui/components/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { type ReactNode, Suspense } from 'react';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';

import { useAvatarImageCache } from '../hooks/image-cache';

const getNameInitials = (name?: string) => {
// Split on whitespace and take first letter of each word
const words = name?.toUpperCase().split(' ') || [];
Expand All @@ -21,51 +23,12 @@ const getNameInitials = (name?: string) => {
return `${firstWord.charAt(0)}${lastWord ? lastWord.charAt(0) : ''}`;
};

// https://css-tricks.com/pre-caching-image-with-react-suspense/
// can be improved when api sends image expiry headers
class ImageCache {
__cache: Record<string, { value: Promise<string> | string; timestamp: number }> = {};
ttl: number;

constructor({ ttl }: { ttl: number }) {
this.ttl = ttl;
}

read(src: string) {
const now = Date.now();
if (this.__cache[src] && typeof this.__cache[src].value !== 'string') {
// If the value is a Promise, throw it to indicate that the cache is still loading
throw this.__cache[src].value;
} else if (this.__cache[src] && now - this.__cache[src].timestamp < this.ttl) {
// If the value is a string and hasn't expired, return it
return this.__cache[src].value;
} else {
// Otherwise, load the image and add it to the cache
const promise = new Promise<string>(resolve => {
const img = new Image();
img.onload = () => {
const value = src;
this.__cache[src] = { value, timestamp: now };
resolve(value);
};
img.src = src;
});
this.__cache[src] = { value: promise, timestamp: now };
throw promise;
}
}
}

// Cache images for 10 minutes
const imgCache = new ImageCache({ ttl: 1000 * 60 * 10 });

// The Image component will Suspend while the image is loading if it's not available in the cache
const AvatarImage = ({ src, alt, size }: { src: string; alt: string; size: 'small' | 'medium' }) => {
imgCache.read(src);
const imageUrl = useAvatarImageCache(src);
return (
<img
alt={alt}
src={src}
src={imageUrl}
width={size === 'small' ? 20 : 24}
height={size === 'small' ? 20 : 24}
className={'border-2 bounce-in border-solid border-[--color-bg] box-border outline-none rounded-full object-cover object-center bg-[--hl]'}
Expand Down
62 changes: 16 additions & 46 deletions packages/insomnia/src/ui/components/organization-avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { Suspense } from 'react';

import { useAvatarImageCache } from '../hooks/image-cache';

const getNameInitials = (name: string) => {
// Split on whitespace and take first letter of each word
const words = name.toUpperCase().split(' ');
Expand All @@ -20,52 +22,16 @@ const getNameInitials = (name: string) => {
return `${firstWord.charAt(0)}${lastWord ? lastWord.charAt(0) : ''}`;
};

// https://css-tricks.com/pre-caching-image-with-react-suspense/
// can be improved when api sends image expiry headers
class ImageCache {
__cache: Record<string, { value: Promise<string> | string; timestamp: number }> = {};
ttl: number;

constructor({ ttl }: { ttl: number }) {
this.ttl = ttl;
}

read(src: string) {
const now = Date.now();
if (this.__cache[src] && typeof this.__cache[src].value !== 'string') {
// If the value is a Promise, throw it to indicate that the cache is still loading
throw this.__cache[src].value;
} else if (this.__cache[src] && now - this.__cache[src].timestamp < this.ttl) {
// If the value is a string and hasn't expired, return it
return this.__cache[src].value;
} else {
// Otherwise, load the image and add it to the cache
const promise = new Promise<string>(resolve => {
const img = new Image();
img.onload = () => {
const value = src;
this.__cache[src] = { value, timestamp: now };
resolve(value);
};
img.src = src;
});
this.__cache[src] = { value: promise, timestamp: now };
throw promise;
}
}
}

// Cache images for 10 minutes
const imgCache = new ImageCache({ ttl: 1000 * 60 * 10 });

const Avatar = ({ src, alt }: { src: string; alt: string }) => {
imgCache.read(src);
return <img
src={src}
alt={alt}
className='h-full w-full aspect-square object-cover'
aria-label={alt}
/>;
const imageUrl = useAvatarImageCache(src);
return (
<img
alt={alt}
src={imageUrl}
className="h-full w-full aspect-square object-cover"
aria-label={alt}
/>
);
};

export const OrganizationAvatar = ({
Expand All @@ -76,7 +42,11 @@ export const OrganizationAvatar = ({
alt: string;
}) => {
if (!src) {
return <div className='flex items-center justify-center w-full h-full p-[--padding-md]'>{getNameInitials(alt)}</div>;
return (
<div className="flex items-center justify-center w-full h-full p-[--padding-md]">
{getNameInitials(alt)}
</div>
);
}

return (
Expand Down
7 changes: 1 addition & 6 deletions packages/insomnia/src/ui/components/present-users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,5 @@ export const PresentUsers = () => {
};
});

return (
<AvatarGroup
size="medium"
items={activeUsers}
/>
);
return <AvatarGroup size="medium" items={activeUsers} />;
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { createContext, type FC, type PropsWithChildren, useContext, useEffect, useState } from 'react';
import { useFetcher, useParams, useRouteLoaderData } from 'react-router-dom';

import { CDN_INVALIDATION_TTL } from '../../../common/constants';
import { insomniaFetch } from '../../../ui/insomniaFetch';
import { avatarImageCache } from '../../hooks/image-cache';
import type { ProjectIdLoaderData } from '../../routes/project';
import { useRootLoaderData } from '../../routes/root';
import type { WorkspaceLoaderData } from '../../routes/workspace';
Expand Down Expand Up @@ -141,8 +143,17 @@ export const InsomniaEventStreamProvider: FC<PropsWithChildren> = ({ children })
return true;
}));
} else if (event.type === 'PresentStateChanged') {
setPresence(prev => [...prev.filter(p => p.acct !== event.acct), event]);
setPresence(prev => {
if (!prev.find(p => p.avatar === event.avatar)) {
// if this avatar is new, invalidate the cache
window.setTimeout(() => avatarImageCache.invalidate(event.avatar), CDN_INVALIDATION_TTL);
}
return [...prev.filter(p => p.acct !== event.acct), event];
});
} else if (event.type === 'OrganizationChanged') {
if (event.avatar) {
window.setTimeout(() => avatarImageCache.invalidate(event.avatar), CDN_INVALIDATION_TTL);
}
syncOrganizationsFetcher.submit({}, {
action: '/organization/sync',
method: 'POST',
Expand Down
170 changes: 170 additions & 0 deletions packages/insomnia/src/ui/hooks/image-cache.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';

interface CacheEntry {
value: Promise<string> | string;
timestamp: number;
version?: string;
subscribers: (() => void)[];
}

// Adopted from https://css-tricks.com/pre-caching-image-with-react-suspense/
class ImageCache {
__cache: Record<string, CacheEntry> = {};
ttl: number;

constructor({ ttl }: { ttl: number }) {
this.ttl = ttl;
}

read(base: string, version: string) {
const value = `${base}${version ? `?${version}` : ''}`;
const now = Date.now();
if (this.__cache[base] && this.__cache[base].value instanceof Promise) {
// If the value is a Promise, throw it to indicate that the cache is still loading
throw this.__cache[base].value;
} else if (
this.__cache[base] &&
(this.__cache[base].version === version ||
now - this.__cache[base].timestamp < this.ttl)
) {
// If the value is an HTMLImageElement, the version matches, and hasn't expired, return it
return this.__cache[base].value;
} else {
// Otherwise, load the image and add it to the cache
const promise = new Promise<string>(resolve => {
const img = new Image();
img.onload = () => {
if (!this.__cache[base]) {
this.__cache[base] = {
value,
timestamp: now,
version,
subscribers: [],
};
} else {
this.__cache[base].value = value;
this.__cache[base].timestamp = now;
this.__cache[base].version = version;
}
resolve(value);
// Notify all subscribers
if (!this.__cache[base].subscribers) {
this.__cache[base].subscribers = [];
}
this.__cache[base].subscribers.forEach(callback => callback());
};
img.onerror = () => {
// infinitely suspended if the image fails to load
this.__cache[base].value = new Promise(() => {});
throw this.__cache[base].value;
};
img.src = value;
});
this.__cache[base].value = promise;
this.__cache[base].timestamp = now;
this.__cache[base].version = version;
if (!this.__cache[base].subscribers) {
this.__cache[base].subscribers = [];
}
throw promise;
}
}

subscribe(base: string, callback: () => void) {
if (!this.__cache[base]) {
this.__cache[base] = {
value: new Promise(() => {}),
timestamp: 0,
subscribers: [],
};
}
if (!this.__cache[base].subscribers) {
this.__cache[base].subscribers = [];
}
if (!this.__cache[base].subscribers.includes(callback)) {
this.__cache[base].subscribers.push(callback);
}
return () => {
if (this.__cache[base] && this.__cache[base].subscribers) {
this.__cache[base].subscribers = this.__cache[base].subscribers.filter(
cb => cb !== callback
);
}
};
}

invalidate(src: string) {
const [base, version] = src.split('?');
if (this.__cache[base] && this.__cache[base].version !== version) {
this.__cache[base].timestamp = 0;
this.read(base, version);
}
}

invalidateAll() {
Object.keys(this.__cache).forEach(src => this.invalidate(src));
}
}

export function useImageCache(src: string, cache: ImageCache): string {
const [base, version] = src.split('?');
const [imageSrc, setImageSrc] = useState<string | null>(null);

const subscribe = useCallback(
(callback: () => void) => {
return cache.subscribe(base, callback);
},
[base, cache]
);

const getSnapshot = () => {
try {
return cache.read(base, version);
} catch (promise) {
if (promise instanceof Promise) {
throw promise;
}
return null;
}
};

const getServerSnapshot = () => null;

useEffect(() => {
setImageSrc(() => {
try {
const result = cache.read(base, version);
if (result instanceof Promise) {
throw result;
}

return result;
} catch (maybeResultPromise) {
if (maybeResultPromise instanceof Promise) {
throw maybeResultPromise;
}
return null;
}
});
}, [cache, base, version]);

const cacheSrc = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);

if (typeof cacheSrc === 'string') {
return cacheSrc;
}

return imageSrc!;
}

export const avatarImageCache = new ImageCache({
ttl: 10 * 60 * 1000, // 10 minutes
});

export function useAvatarImageCache(src: string) {
return useImageCache(src, avatarImageCache);
}

0 comments on commit 44752d0

Please sign in to comment.