Skip to content

Commit

Permalink
feat(clerk-js): Optimize image delivery
Browse files Browse the repository at this point in the history
* fix(clerk-js): Replace deprecated global JSX with React.JSX

* fix(clerk-js): Introduce makeResponsive HOC

Primitives shouldn't be aware of any Clerk-specific logic. All the image optimizatiion logic is now extracted into its own makeResponsive HOC.

* fix(clerk-js): Remove Avatar.optimise prop and isRetina helper

* Create wise-turtles-study.md

* fix(clerk-js): Remove Avatar.optimse prop and isRetina helper
  • Loading branch information
nikosdouvlis authored Jun 16, 2023
1 parent 8b71b46 commit 59bc649
Show file tree
Hide file tree
Showing 14 changed files with 96 additions and 96 deletions.
6 changes: 6 additions & 0 deletions .changeset/wise-turtles-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/clerk-js": patch
"@clerk/shared": patch
---

Optimize all images displayed within the Clerk components, such as Avatars, static OAuth provider assets etc. All images are now resized and compressed. Additionally, all images are automatically converted into more efficient formats (`avif`, `webp`) if they are supported by the user's browser, otherwise all images fall back to `jpeg`.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export const OrganizationProfileAvatarUploader = (
avatarPreview={
<OrganizationAvatar
size={theme => theme.sizes.$11}
optimize
{...organization}
/>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const UserButtonTrigger = withAvatarShimmer(
boxElementDescriptor={descriptors.userButtonAvatarBox}
imageElementDescriptor={descriptors.userButtonAvatarImage}
{...user}
optimize
size={theme => theme.sizes.$8}
/>
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const UserProfileAvatarUploader = (
avatarPreview={
<UserAvatar
size={theme => theme.sizes.$11}
optimize
{...user}
/>
}
Expand Down
5 changes: 4 additions & 1 deletion packages/clerk-js/src/ui/customizables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { makeLocalizable } from '../localization';
import * as Primitives from '../primitives';
import { descriptors } from './elementDescriptors';
import { makeCustomizable } from './makeCustomizable';
import { makeResponsive } from './makeResponsive';
import { sanitizeDomProps } from './sanitizeDomProps';

export * from './Flow';
Expand All @@ -24,7 +25,9 @@ export const SimpleButton = makeCustomizable(makeLocalizable(sanitizeDomProps(Pr
export const Heading = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Heading)));
export const Link = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Link)));
export const Text = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.Text)));
export const Image = makeCustomizable(sanitizeDomProps(Primitives.Image));

export const Image = makeCustomizable(sanitizeDomProps(makeResponsive(Primitives.Image)));

export const Alert = makeCustomizable(sanitizeDomProps(Primitives.Alert));
export const AlertIcon = makeCustomizable(sanitizeDomProps(Primitives.AlertIcon));
export const Input = makeCustomizable(sanitizeDomProps(Primitives.Input));
Expand Down
64 changes: 64 additions & 0 deletions packages/clerk-js/src/ui/customizables/makeResponsive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';

import { isDataUri, isValidUrl } from '../../utils';

type Responsive<T = Record<never, never>> = T & {
size?: number;
xDescriptors?: number[];
};

type ResponsivePrimitive<T> = React.FunctionComponent<Responsive<T>>;

export const makeResponsive = <P extends React.JSX.IntrinsicElements['img']>(
Component: React.FunctionComponent<P>,
): ResponsivePrimitive<P> => {
const responsiveComponent = React.forwardRef((props: Responsive<any>, ref) => {
const { src, size = 80, xDescriptors = [1, 2], ...restProps } = props;
const shouldOptimize = isClerkImage(src);

return (
<Component
srcSet={shouldOptimize ? generateSrcSet({ src, width: size, xDescriptors }) : undefined}
src={shouldOptimize ? generateSrc({ src, width: size * 2 }) : src}
{...restProps}
ref={ref}
/>
);
});

const displayName = Component.displayName || Component.name || 'Component';
responsiveComponent.displayName = `Responsive${displayName}`.replace('_', '');
return responsiveComponent as ResponsivePrimitive<P>;
};

const CLERK_IMAGE_URL_BASES = [
'https://img.clerk.com/',
'https://img.clerk.dev/',
'https://img.clerkstage.dev/',
'https://img.lclclerk.com/',
];

const isClerkImage = (src?: string): boolean => {
return !!CLERK_IMAGE_URL_BASES.some(base => src?.includes(base));
};

const generateSrcSet = ({ src, width, xDescriptors }: { src?: string; width: number; xDescriptors: number[] }) => {
if (!src) {
return '';
}

return xDescriptors.map(i => `${generateSrc({ src, width: width * i })} ${i}x`).toString();
};

const generateSrc = ({ src, width }: { src?: string; width: number }) => {
if (!isValidUrl(src) || isDataUri(src)) {
return src;
}

const newSrc = new URL(src);
if (width) {
newSrc.searchParams.append('width', width?.toString());
}

return newSrc.href;
};
18 changes: 4 additions & 14 deletions packages/clerk-js/src/ui/elements/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isRetinaDisplay } from '@clerk/shared';
import React from 'react';

import { Box, descriptors, Flex, Image, Text } from '../customizables';
Expand All @@ -13,7 +12,6 @@ type AvatarProps = PropsOfComponent<typeof Flex> & {
initials?: string;
imageUrl?: string | null;
imageFetchSize?: number;
optimize?: boolean;
rounded?: boolean;
boxElementDescriptor?: ElementDescriptor;
imageElementDescriptor?: ElementDescriptor;
Expand All @@ -25,36 +23,28 @@ export const Avatar = (props: AvatarProps) => {
title,
initials,
imageUrl,
optimize,
rounded = true,
imageFetchSize = 64,
imageFetchSize = 80,
sx,
boxElementDescriptor,
imageElementDescriptor,
} = props;
const [error, setError] = React.useState(false);

let src = imageUrl;
if (src && !optimize) {
const optimizedHeight = Math.max(imageFetchSize) * (isRetinaDisplay() ? 2 : 1);
const srcUrl = new URL(src);
srcUrl.searchParams.append('height', optimizedHeight.toString());
src = srcUrl.toString();
}

const ImgOrFallback =
initials && (!src || error) ? (
initials && (!imageUrl || error) ? (
<InitialsAvatarFallback initials={initials} />
) : (
<Image
elementDescriptor={[imageElementDescriptor, descriptors.avatarImage]}
title={title}
alt={title}
src={src || ''}
src={imageUrl || ''}
width='100%'
height='100%'
sx={{ objectFit: 'cover' }}
onError={() => setError(true)}
size={imageFetchSize}
/>
);

Expand Down
1 change: 0 additions & 1 deletion packages/clerk-js/src/ui/elements/OrganizationPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => {
imageElementDescriptor={descriptors.organizationPreviewAvatarImage}
{...organization}
size={t => ({ sm: t.sizes.$8, md: t.sizes.$11, lg: t.sizes.$12x5 }[size])}
optimize
sx={avatarSx}
rounded={rounded}
/>
Expand Down
1 change: 0 additions & 1 deletion packages/clerk-js/src/ui/elements/UserPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ export const UserPreview = (props: UserPreviewProps) => {
name={name}
avatarUrl={imageUrl}
size={t => ({ sm: t.sizes.$8, md: t.sizes.$11, lg: t.sizes.$12x5 }[size])}
optimize
sx={avatarSx}
rounded={rounded}
/>
Expand Down
14 changes: 2 additions & 12 deletions packages/clerk-js/src/ui/primitives/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
import React from 'react';

import { generateSrc, generateSrcSet, isClerkImage } from '../../utils';
import type { PrimitiveProps, StateProps } from '../styledSystem';
import { applyDataStateProps } from './applyDataStateProps';

export type ImageProps = PrimitiveProps<'img'> &
StateProps & {
size?: number;
xDescriptors?: number[];
};
export type ImageProps = PrimitiveProps<'img'> & StateProps;

export const Image = React.forwardRef<HTMLImageElement, ImageProps>((props, ref) => {
const { src, size = 80, xDescriptors = [1, 2], ...rest } = props;
const shouldAdjustSize = isClerkImage(src);

return (
<img
crossOrigin='anonymous'
srcSet={shouldAdjustSize ? generateSrcSet({ src, width: size, xDescriptors }) : undefined}
src={shouldAdjustSize ? generateSrc({ src, width: size * 2 }) : src}
{...applyDataStateProps(rest)}
{...applyDataStateProps(props)}
ref={ref}
/>
);
Expand Down
31 changes: 16 additions & 15 deletions packages/clerk-js/src/ui/styledSystem/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import type { Interpolation as _Interpolation } from '@emotion/react';
import type React from 'react';

import type { InternalTheme } from '../foundations';

Expand All @@ -15,21 +16,21 @@ type CssProp = { css?: ThemableCssProp };
export type AsProp = { as?: React.ElementType };

type ElementProps = {
div: JSX.IntrinsicElements['div'];
input: JSX.IntrinsicElements['input'];
button: JSX.IntrinsicElements['button'];
heading: JSX.IntrinsicElements['h1'];
p: JSX.IntrinsicElements['p'];
a: JSX.IntrinsicElements['a'];
label: JSX.IntrinsicElements['label'];
img: JSX.IntrinsicElements['img'];
form: JSX.IntrinsicElements['form'];
table: JSX.IntrinsicElements['table'];
thead: JSX.IntrinsicElements['thead'];
tbody: JSX.IntrinsicElements['tbody'];
th: JSX.IntrinsicElements['th'];
tr: JSX.IntrinsicElements['tr'];
td: JSX.IntrinsicElements['td'];
div: React.JSX.IntrinsicElements['div'];
input: React.JSX.IntrinsicElements['input'];
button: React.JSX.IntrinsicElements['button'];
heading: React.JSX.IntrinsicElements['h1'];
p: React.JSX.IntrinsicElements['p'];
a: React.JSX.IntrinsicElements['a'];
label: React.JSX.IntrinsicElements['label'];
img: React.JSX.IntrinsicElements['img'];
form: React.JSX.IntrinsicElements['form'];
table: React.JSX.IntrinsicElements['table'];
thead: React.JSX.IntrinsicElements['thead'];
tbody: React.JSX.IntrinsicElements['tbody'];
th: React.JSX.IntrinsicElements['th'];
tr: React.JSX.IntrinsicElements['tr'];
td: React.JSX.IntrinsicElements['td'];
};

/**
Expand Down
39 changes: 0 additions & 39 deletions packages/clerk-js/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,6 @@ declare global {

// This is used as a dummy base when we need to invoke "new URL()" but we don't care about the URL origin.
const DUMMY_URL_BASE = 'http://clerk-dummy';
const CLERK_IMAGE_URL_BASES = [
'https://images.clerk.com/',
'https://images.clerk.dev/',
'https://images.clerkstage.dev/',
'https://images.lclclerk.com/',
];

export const DEV_OR_STAGING_SUFFIXES = [
'.lcl.dev',
Expand Down Expand Up @@ -258,39 +252,6 @@ export function hasBannedProtocol(val: string | URL) {
return BANNED_URI_PROTOCOLS.some(bp => bp === protocol);
}

export const isClerkImage = (src?: string): boolean => {
return !!CLERK_IMAGE_URL_BASES.some(base => src?.includes(base));
};

export const generateSrc = ({ src, width }: { src?: string; width: number }) => {
if (!isValidUrl(src) || isDataUri(src)) {
return src;
}

const newSrc = new URL(src);
if (width) {
newSrc.searchParams.append('width', width?.toString());
}

return newSrc.href;
};

export const generateSrcSet = ({
src,
width,
xDescriptors,
}: {
src?: string;
width: number;
xDescriptors: number[];
}) => {
if (!src) {
return '';
}

return xDescriptors.map(i => `${generateSrc({ src, width: width * i })} ${i}x`).toString();
};

export const hasUrlInFragment = (_url: URL | string) => {
return new URL(_url, DUMMY_URL_BASE).hash.startsWith('#/');
};
Expand Down
1 change: 0 additions & 1 deletion packages/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export * from './date';
export * from './errors';
export * from './file';
export * from './keys';
export * from './isRetinaDisplay';
export * from './localStorageBroadcastChannel';
export * from './mimeTypeExtensions';
export * from './multiDomain';
Expand Down
9 changes: 0 additions & 9 deletions packages/shared/src/utils/isRetinaDisplay.ts

This file was deleted.

0 comments on commit 59bc649

Please sign in to comment.