fix(ui): browser image caching cors race condition

Must set cross origin whenever we load an image from a URL to prevent
race conditions where browser caches an image with no CORS, then canvas
attempts to load it with CORS, resulting in browser rejecting the
request before it is made
This commit is contained in:
psychedelicious
2025-09-03 15:28:33 +10:00
committed by Mary Hipp Rogers
parent 75daef2aba
commit 0e523ca2c1
17 changed files with 87 additions and 4 deletions

View File

@@ -1,6 +1,11 @@
import { atom } from 'nanostores';
import { atom, computed } from 'nanostores';
/**
* The user's auth token.
*/
export const $authToken = atom<string | undefined>();
/**
* The crossOrigin value to use for all image loading. Depends on whether the user is authenticated.
*/
export const $crossOrigin = computed($authToken, (token) => (token ? 'use-credentials' : 'anonymous'));

View File

@@ -1,6 +1,8 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, Icon, IconButton, Image, Skeleton, Text, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { round } from 'es-toolkit/compat';
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
@@ -68,6 +70,7 @@ export const RefImagePreview = memo(() => {
const dispatch = useAppDispatch();
const id = useRefImageIdContext();
const entity = useRefImageEntity(id);
const crossOrigin = useStore($crossOrigin);
const mainModelConfig = useAppSelector(selectMainModelConfig);
const selectedEntityId = useAppSelector(selectSelectedRefEntityId);
const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen);
@@ -146,6 +149,7 @@ export const RefImagePreview = memo(() => {
>
<Image
src={imageDTO?.thumbnail_url}
crossOrigin={crossOrigin}
objectFit="contain"
aspectRatio="1/1"
height={imageDTO?.height}

View File

@@ -1,5 +1,5 @@
import type { Selector, Store } from '@reduxjs/toolkit';
import { $authToken } from 'app/store/nanostores/authToken';
import { $authToken, $crossOrigin } from 'app/store/nanostores/authToken';
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
import { clamp } from 'es-toolkit/compat';
import type {
@@ -494,7 +494,7 @@ export async function loadImage(src: string, fetchUrlFirst?: boolean): Promise<H
const imageElement = new Image();
imageElement.onload = () => resolve(imageElement);
imageElement.onerror = (error) => reject(error);
imageElement.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous';
imageElement.crossOrigin = $crossOrigin.get();
imageElement.src = url;
});
}

View File

@@ -2,6 +2,8 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useAppStore } from 'app/store/storeHooks';
import { singleImageDndSource } from 'features/dnd/dnd';
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
@@ -29,6 +31,8 @@ type Props = {
export const DndImage = memo(
forwardRef(({ imageDTO, asThumbnail, ...rest }: Props, forwardedRef) => {
const store = useAppStore();
const crossOrigin = useStore($crossOrigin);
const [isDragging, setIsDragging] = useState(false);
const ref = useRef<HTMLImageElement>(null);
useImperativeHandle(forwardedRef, () => ref.current!, []);
@@ -76,6 +80,7 @@ export const DndImage = memo(
height={imageDTO.height}
sx={sx}
data-is-dragging={isDragging}
crossOrigin={crossOrigin}
{...rest}
/>
{dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null}

View File

@@ -1,5 +1,7 @@
import { Flex, Image, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useTranslation } from 'react-i18next';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@@ -16,6 +18,8 @@ type Props = {
export const BoardTooltip = ({ board, boardCounts }: Props) => {
const { t } = useTranslation();
const crossOrigin = useStore($crossOrigin);
const isVideoEnabled = useFeatureStatus('video');
const { currentData: coverImage } = useGetImageDTOQuery(board?.cover_image_name ?? skipToken);
@@ -25,6 +29,7 @@ export const BoardTooltip = ({ board, boardCounts }: Props) => {
{coverImage && (
<Image
src={coverImage.thumbnail_url}
crossOrigin={crossOrigin}
draggable={false}
objectFit="cover"
maxW={150}

View File

@@ -1,6 +1,8 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { AddImageToBoardDndTargetData } from 'features/dnd/dnd';
import { addImageToBoardDndTarget } from 'features/dnd/dnd';
@@ -34,6 +36,7 @@ interface GalleryBoardProps {
const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick);
const selectedBoardId = useAppSelector(selectSelectedBoardId);
@@ -111,12 +114,14 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
export default memo(GalleryBoard);
const CoverImage = ({ board }: { board: BoardDTO }) => {
const crossOrigin = useStore($crossOrigin);
const { currentData: coverImage } = useGetImageDTOQuery(board.cover_image_name ?? skipToken);
if (coverImage) {
return (
<Image
src={coverImage.thumbnail_url}
crossOrigin={crossOrigin}
draggable={false}
objectFit="cover"
w={10}

View File

@@ -2,7 +2,9 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { FlexProps } from '@invoke-ai/ui-library';
import { Flex, Icon, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import type { AppDispatch, AppGetState } from 'app/store/store';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { uniq } from 'es-toolkit';
@@ -87,6 +89,8 @@ const buildOnClick =
export const GalleryImage = memo(({ imageDTO }: Props) => {
const store = useAppStore();
const crossOrigin = useStore($crossOrigin);
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewState, setDragPreviewState] = useState<
DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null
@@ -210,6 +214,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
<Image
pointerEvents="none"
src={imageDTO.thumbnail_url}
crossOrigin={crossOrigin}
w={imageDTO.width}
fallback={<GalleryImagePlaceholder />}
objectFit="contain"

View File

@@ -1,7 +1,9 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { Flex, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import type { AppDispatch, AppGetState } from 'app/store/store';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { uniq } from 'es-toolkit';
@@ -83,6 +85,8 @@ const buildOnClick =
export const GalleryVideo = memo(({ videoDTO }: Props) => {
const store = useAppStore();
const crossOrigin = useStore($crossOrigin);
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewState, setDragPreviewState] = useState<
DndDragPreviewSingleVideoState | DndDragPreviewMultipleVideoState | null
@@ -200,6 +204,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
<Image
pointerEvents="none"
src={videoDTO.thumbnail_url}
crossOrigin={crossOrigin}
w={videoDTO.width}
fallback={<GalleryVideoPlaceholder />}
objectFit="contain"

View File

@@ -1,4 +1,6 @@
import { Box, Flex, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useAppSelector } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { preventDefault } from 'common/util/stopPropagation';
@@ -12,6 +14,8 @@ import type { ComparisonProps } from './common';
import { fitDimsToContainer, getSecondImageDims } from './common';
export const ImageComparisonHover = memo(({ firstImage, secondImage, rect }: ComparisonProps) => {
const crossOrigin = useStore($crossOrigin);
const comparisonFit = useAppSelector(selectComparisonFit);
const imageContainerRef = useRef<HTMLDivElement>(null);
const mouseOver = useBoolean(false);
@@ -53,6 +57,7 @@ export const ImageComparisonHover = memo(({ firstImage, secondImage, rect }: Com
id="image-comparison-hover-first-image"
src={firstImage.image_url}
fallbackSrc={firstImage.thumbnail_url}
crossOrigin={crossOrigin}
w={fittedDims.width}
h={fittedDims.height}
maxW="full"
@@ -89,6 +94,7 @@ export const ImageComparisonHover = memo(({ firstImage, secondImage, rect }: Com
id="image-comparison-hover-second-image"
src={secondImage.image_url}
fallbackSrc={secondImage.thumbnail_url}
crossOrigin={crossOrigin}
w={compareImageDims.width}
h={compareImageDims.height}
maxW={fittedDims.width}

View File

@@ -1,4 +1,6 @@
import { Flex, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import type { ComparisonProps } from 'features/gallery/components/ImageViewer/common';
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
import { VerticalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
@@ -41,6 +43,8 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Comp
ImageComparisonSideBySide.displayName = 'ImageComparisonSideBySide';
const SideBySideImage = memo(({ imageDTO, type }: { imageDTO: ImageDTO; type: 'first' | 'second' }) => {
const crossOrigin = useStore($crossOrigin);
return (
<Flex position="relative" w="full" h="full" alignItems="center" justifyContent="center">
<Flex position="absolute" maxW="full" maxH="full" aspectRatio={imageDTO.width / imageDTO.height}>
@@ -52,6 +56,7 @@ const SideBySideImage = memo(({ imageDTO, type }: { imageDTO: ImageDTO; type: 'f
maxH="full"
src={imageDTO.image_url}
fallbackSrc={imageDTO.thumbnail_url}
crossOrigin={crossOrigin}
objectFit="contain"
borderRadius="base"
/>

View File

@@ -1,4 +1,6 @@
import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useAppSelector } from 'app/store/storeHooks';
import { preventDefault } from 'common/util/stopPropagation';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
@@ -21,6 +23,7 @@ const HANDLE_LEFT_INITIAL_PX = `calc(${INITIAL_POS} - ${HANDLE_HITBOX / 2}px)`;
export const ImageComparisonSlider = memo(({ firstImage, secondImage, rect }: ComparisonProps) => {
const comparisonFit = useAppSelector(selectComparisonFit);
const crossOrigin = useStore($crossOrigin);
// How far the handle is from the left - this will be a CSS calculation that takes into account the handle width
const [left, setLeft] = useState(HANDLE_LEFT_INITIAL_PX);
@@ -132,6 +135,7 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage, rect }: Co
id="image-comparison-second-image"
src={secondImage.image_url}
fallbackSrc={secondImage.thumbnail_url}
crossOrigin={crossOrigin}
w={compareImageDims.width}
h={compareImageDims.height}
maxW={fittedDims.width}
@@ -154,6 +158,7 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage, rect }: Co
id="image-comparison-first-image"
src={firstImage.image_url}
fallbackSrc={firstImage.thumbnail_url}
crossOrigin={crossOrigin}
w={fittedDims.width}
h={fittedDims.height}
objectFit="cover"

View File

@@ -1,4 +1,6 @@
import { Flex, Icon, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { typedMemo } from 'common/util/typedMemo';
import { PiImage } from 'react-icons/pi';
@@ -10,6 +12,8 @@ export const MODEL_IMAGE_THUMBNAIL_SIZE = '40px';
const FALLBACK_ICON_SIZE = '24px';
const ModelImage = ({ image_url }: Props) => {
const crossOrigin = useStore($crossOrigin);
if (!image_url) {
return (
<Flex
@@ -27,6 +31,7 @@ const ModelImage = ({ image_url }: Props) => {
return (
<Image
src={image_url}
crossOrigin={crossOrigin}
objectFit="cover"
objectPosition="50% 50%"
height={MODEL_IMAGE_THUMBNAIL_SIZE}

View File

@@ -1,4 +1,6 @@
import { Box, IconButton, Image } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { dropzoneAccept } from 'common/hooks/useImageUploadButton';
import { typedMemo } from 'common/util/typedMemo';
import { toast } from 'features/toast/toast';
@@ -14,6 +16,8 @@ type Props = {
};
const ModelImageUpload = ({ model_key, model_image }: Props) => {
const crossOrigin = useStore($crossOrigin);
const [image, setImage] = useState<string | null>(model_image || null);
const { t } = useTranslation();
@@ -84,6 +88,7 @@ const ModelImageUpload = ({ model_key, model_image }: Props) => {
<Box position="relative" flexShrink={0}>
<Image
src={image}
crossOrigin={crossOrigin}
objectFit="cover"
objectPosition="50% 50%"
height={108}

View File

@@ -1,6 +1,8 @@
import type { FormControlProps } from '@invoke-ai/ui-library';
import { Box, Flex, FormControl, FormControlGroup, FormLabel, Image, Input, Textarea } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import {
@@ -148,6 +150,7 @@ const formControlProps: FormControlProps = {
const Thumbnail = ({ id }: { id?: string | null }) => {
const { t } = useTranslation();
const crossOrigin = useStore($crossOrigin);
const { data } = useGetWorkflowQuery(id ?? skipToken);
@@ -163,6 +166,7 @@ const Thumbnail = ({ id }: { id?: string | null }) => {
<Box position="relative" flexShrink={0}>
<Image
src={data.thumbnail_url}
crossOrigin={crossOrigin}
objectFit="cover"
objectPosition="50% 50%"
w={100}

View File

@@ -1,5 +1,7 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { LockedWorkflowIcon } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon';
import { ShareWorkflowButton } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow';
@@ -36,6 +38,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
const dispatch = useAppDispatch();
const workflowId = useAppSelector(selectWorkflowId);
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const crossOrigin = useStore($crossOrigin);
const isActive = useMemo(() => {
return workflowId === workflow.workflow_id;
@@ -66,6 +69,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
<Flex p={2} pr={0}>
<Image
src={workflow.thumbnail_url ?? undefined}
crossOrigin={crossOrigin}
fallbackStrategy="beforeLoadOrError"
fallback={workflow.category === 'default' ? <DefaultThumbnailFallback /> : <UserThumbnailFallback />}
objectFit="cover"

View File

@@ -1,5 +1,6 @@
import { Box, Flex, Image, Spinner, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { PromptExpansionResultOverlay } from 'features/prompt/PromptExpansion/PromptExpansionResultOverlay';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,6 +11,7 @@ import { promptExpansionApi } from './state';
export const PromptExpansionOverlay = memo(() => {
const { isSuccess, isPending, result, imageDTO } = useStore(promptExpansionApi.$state);
const { t } = useTranslation();
const crossOrigin = useStore($crossOrigin);
// Show result overlay when completed
if (isSuccess) {
@@ -48,7 +50,14 @@ export const PromptExpansionOverlay = memo(() => {
borderRadius="base"
overflow="hidden"
>
<Image src={imageDTO.thumbnail_url} objectFit="contain" w="full" h="full" borderRadius="base" />
<Image
src={imageDTO.thumbnail_url}
crossOrigin={crossOrigin}
objectFit="contain"
w="full"
h="full"
borderRadius="base"
/>
</Box>
)}

View File

@@ -1,4 +1,6 @@
import { Flex, Icon, Image, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $crossOrigin } from 'app/store/nanostores/authToken';
import { typedMemo } from 'common/util/typedMemo';
import { PiImage } from 'react-icons/pi';
@@ -6,6 +8,8 @@ const IMAGE_THUMBNAIL_SIZE = '40px';
const FALLBACK_ICON_SIZE = '24px';
const StylePresetImage = ({ presetImageUrl, imageWidth }: { presetImageUrl: string | null; imageWidth?: number }) => {
const crossOrigin = useStore($crossOrigin);
return (
<Tooltip
closeOnScroll
@@ -14,6 +18,7 @@ const StylePresetImage = ({ presetImageUrl, imageWidth }: { presetImageUrl: stri
presetImageUrl && (
<Image
src={presetImageUrl}
crossOrigin={crossOrigin}
draggable={false}
objectFit="cover"
maxW={150}
@@ -27,6 +32,7 @@ const StylePresetImage = ({ presetImageUrl, imageWidth }: { presetImageUrl: stri
>
<Image
src={presetImageUrl || ''}
crossOrigin={crossOrigin}
fallbackStrategy="beforeLoadOrError"
fallback={
<Flex