diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json
index 3111e106..a6ecd3a2 100644
--- a/packages/react-sdk/package.json
+++ b/packages/react-sdk/package.json
@@ -35,12 +35,14 @@
"react-beautiful-dnd": "^13.1.1",
"react-draggable": "^4.4.6",
"react-dropzone": "^14.2.9",
+ "react-error-boundary": "^4.1.2",
"react-hotkeys-hook": "^4.5.0",
"react-joyride": "^2.9.2",
"react-redux": "^9.1.2",
"react-transition-group": "^4.4.5",
"react-use": "^17.5.1",
"rxjs": "^7.8.1",
+ "swr": "^2.2.5",
"tinycolor2": "^1.6.0",
"yjs": "^13.6.19"
},
diff --git a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx
index 32dac3c5..54c5c3a7 100644
--- a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx
+++ b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx
@@ -15,7 +15,7 @@
*/
import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ComponentType, PropsWithChildren } from 'react';
import {
MockInstance,
@@ -31,6 +31,7 @@ import {
mockImageElement,
mockWhiteboardManager,
} from '../../../lib/testUtils/documentTestUtils';
+import { ImageMimeType } from '../../../state';
import { LayoutStateProvider } from '../../Layout';
import { SlidesProvider } from '../../Layout/SlidesProvider';
import { SvgCanvas } from '../SvgCanvas';
@@ -112,4 +113,123 @@ describe('', () => {
screen.queryByTestId('element-element-0-image'),
).not.toBeInTheDocument();
});
+
+ it('should render an error when an image is not available', async () => {
+ vi.mocked(URL.createObjectURL).mockReturnValue('');
+
+ // @ts-ignore ignore readonly prop for tests
+ widgetApi.widgetParameters.baseUrl = 'https://example.com';
+
+ render(
+ ,
+ { wrapper: Wrapper },
+ );
+
+ expect(
+ screen.getByTestId('element-element-0-skeleton'),
+ ).toBeInTheDocument();
+
+ const errorContainer = await screen.findByTestId(
+ 'element-element-0-error-container',
+ );
+ expect(errorContainer).toBeInTheDocument();
+
+ const imageElement = screen.queryByTestId('element-element-0-image');
+ expect(imageElement).not.toBeInTheDocument();
+
+ vi.mocked(URL.createObjectURL).mockReset();
+ });
+
+ it.each([
+ ['example.gif', 'image/gif'],
+ ['example.jpeg', 'image/jpeg'],
+ ['example.png', 'image/png'],
+ ['example.svg', 'image/svg+xml'],
+ ] as [string, ImageMimeType][])(
+ 'should render %s without exploding',
+ async (fileName, mimeType) => {
+ widgetApi = mockWidgetApi();
+ consoleSpy = vi.spyOn(console, 'error');
+
+ vi.mocked(URL.createObjectURL).mockReturnValue('http://...');
+
+ const imageElementMock = mockImageElement();
+ imageElementMock.fileName = fileName;
+ imageElementMock.mimeType = mimeType;
+
+ const { whiteboardManager } = mockWhiteboardManager({
+ slides: [['slide-0', [['element-0', imageElementMock]]]],
+ });
+
+ Wrapper = ({ children }) => (
+
+
+
+
+ {children}
+
+
+
+
+ );
+
+ // @ts-ignore ignore readonly prop for tests
+ widgetApi.widgetParameters.baseUrl = 'https://example.com';
+
+ render(
+ ,
+ { wrapper: Wrapper },
+ );
+
+ expect(
+ screen.getByTestId('element-element-0-skeleton'),
+ ).toBeInTheDocument();
+ const imageElement = await screen.findByTestId('element-element-0-image');
+ expect(imageElement).toBeInTheDocument();
+ },
+ );
+
+ it('should render a skeleton until an image is loaded', async () => {
+ // @ts-ignore ignore readonly prop for tests
+ widgetApi.widgetParameters.baseUrl = 'https://example.com';
+ render(
+ ,
+ { wrapper: Wrapper },
+ );
+
+ expect(
+ screen.getByTestId('element-element-0-skeleton'),
+ ).toBeInTheDocument();
+
+ const imageElement = await screen.findByTestId('element-element-0-image');
+ expect(imageElement).toBeInTheDocument();
+ fireEvent.load(imageElement);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId('element-element-0-skeleton'),
+ ).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx
index d2142991..5a640d0e 100644
--- a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx
+++ b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx
@@ -14,7 +14,6 @@
* limitations under the License.
*/
-import { useWidgetApi } from '@matrix-widget-toolkit/react';
import { Elements } from '../../../state/types';
import { useElementOverride } from '../../ElementOverridesProvider';
import EllipseDisplay from '../../elements/ellipse/Display';
@@ -24,6 +23,14 @@ import PolylineDisplay from '../../elements/polyline/Display';
import RectangleDisplay from '../../elements/rectangle/Display';
import TriangleDisplay from '../../elements/triangle/Display';
+export type OtherProps = {
+ active: boolean;
+ readOnly: boolean;
+ elementId: string;
+ activeElementIds: string[];
+ overrides: Elements;
+};
+
export const ConnectedElement = ({
id,
readOnly = false,
@@ -35,13 +42,12 @@ export const ConnectedElement = ({
activeElementIds?: string[];
overrides?: Elements;
}) => {
- const widgetApi = useWidgetApi();
const element = useElementOverride(id);
const isActive =
!readOnly && id
? activeElementIds.length === 1 && activeElementIds[0] === id
: false;
- const otherProps = {
+ const otherProps: OtherProps = {
// TODO: Align names
active: isActive,
readOnly,
@@ -66,18 +72,7 @@ export const ConnectedElement = ({
return ;
}
} else if (element.type === 'image') {
- if (widgetApi.widgetParameters.baseUrl === undefined) {
- console.error('Image cannot be rendered due to missing base URL');
- return null;
- }
-
- return (
-
- );
+ return ;
}
}
diff --git a/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx b/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx
index 19f2037f..b40a0907 100644
--- a/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx
+++ b/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx
@@ -15,7 +15,7 @@
*/
import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import { ComponentType, PropsWithChildren } from 'react';
import {
afterEach,
@@ -31,7 +31,6 @@ import {
mockWhiteboardManager,
WhiteboardTestingContextProvider,
} from '../../../lib/testUtils/documentTestUtils';
-import { ImageMimeType } from '../../../state';
import { LayoutStateProvider } from '../../Layout';
import { SlidesProvider } from '../../Layout/SlidesProvider';
import { whiteboardHeight, whiteboardWidth } from '../../Whiteboard';
@@ -86,121 +85,25 @@ describe('', () => {
fetch.resetMocks();
});
- it.each([
- ['example.gif', 'image/gif'],
- ['example.jpeg', 'image/jpeg'],
- ['example.png', 'image/png'],
- ['example.svg', 'image/svg+xml'],
- ] as [string, ImageMimeType][])(
- 'should render %s without exploding',
- async (fileName, mimeType) => {
- render(
- ,
- { wrapper: Wrapper },
- );
-
- expect(
- screen.getByTestId('element-element-0-skeleton'),
- ).toBeInTheDocument();
- const imageElement = await screen.findByTestId('element-element-0-image');
- expect(imageElement).toBeInTheDocument();
- },
- );
-
- it('should render a skeleton until an image is loaded', async () => {
- render(
- ,
- { wrapper: Wrapper },
- );
-
- expect(
- screen.getByTestId('element-element-0-skeleton'),
- ).toBeInTheDocument();
-
- const imageElement = await screen.findByTestId('element-element-0-image');
- expect(imageElement).toBeInTheDocument();
- fireEvent.load(imageElement);
-
- await waitFor(() => {
- expect(
- screen.queryByTestId('element-element-0-skeleton'),
- ).not.toBeInTheDocument();
- });
- });
-
- it('should render an error when an image is not available', async () => {
- vi.mocked(URL.createObjectURL).mockReturnValue('');
-
- render(
- ,
- { wrapper: Wrapper },
- );
-
- expect(
- screen.getByTestId('element-element-0-skeleton'),
- ).toBeInTheDocument();
-
- const errorContainer = await screen.findByTestId(
- 'element-element-0-error-container',
- );
- expect(errorContainer).toBeInTheDocument();
-
- const imageElement = screen.queryByTestId('element-element-0-image');
- expect(imageElement).not.toBeInTheDocument();
-
- vi.mocked(URL.createObjectURL).mockReset();
- });
-
it('should not have a context menu in read-only mode', () => {
render(
,
{ wrapper: Wrapper },
);
diff --git a/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx b/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx
index 82c520a7..3f64f762 100644
--- a/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx
+++ b/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx
@@ -14,10 +14,13 @@
* limitations under the License.
*/
+import { WidgetApi } from '@matrix-widget-toolkit/api';
import { useWidgetApi } from '@matrix-widget-toolkit/react';
import { styled } from '@mui/material';
-import { IDownloadFileActionFromWidgetResponseData } from 'matrix-widget-api';
-import React, { useCallback, useEffect, useState } from 'react';
+import { getLogger } from 'loglevel';
+import { Suspense, useCallback, useEffect, useState } from 'react';
+import { ErrorBoundary } from 'react-error-boundary';
+import useSWR from 'swr';
import { convertMxcToHttpUrl, WidgetApiActionError } from '../../../lib';
import { ImageElement } from '../../../state';
import {
@@ -26,9 +29,106 @@ import {
SelectableElement,
WithExtendedSelectionProps,
} from '../../Whiteboard';
+import { OtherProps } from '../../Whiteboard/Element/ConnectedElement';
import { ImagePlaceholder } from './ImagePlaceholder';
import { Skeleton } from './Skeleton';
+/**
+ * Download a file from the widget API or fallback to HTTP download.
+ *
+ * @returns The data URL of the downloaded file. This can be a blob url or a http url.
+ */
+const downloadFile = async ({
+ widgetApi,
+ baseUrl,
+ mxc,
+ mimeType,
+}: {
+ widgetApi: WidgetApi;
+ baseUrl: string;
+ mxc: string;
+ mimeType: string;
+}): Promise => {
+ try {
+ const result = await tryDownloadFileWithWidgetApi(widgetApi, mxc);
+
+ // Convert the widget API response to a Blob with given size and type
+ if (!(result.file instanceof Blob)) {
+ throw new Error('Got non Blob file response');
+ }
+ const blob = result.file.slice(0, result.file.size, mimeType);
+
+ return blob;
+ } catch (error) {
+ const logger = getLogger('ImageDisplay.downloadFile');
+ if (error instanceof WidgetApiActionError) {
+ logger.warn(
+ 'Widget API downloadFile not available, falling back to HTTP download',
+ );
+ return tryFallbackDownload(mxc, baseUrl, mimeType);
+ } else {
+ logger.error('Failed to download image:', error);
+ throw error;
+ }
+ }
+};
+
+/**
+ * Try to download a file using the widget API.
+ *
+ * @param widgetApi The widget API to use for downloading the file
+ * @param mxc The mxc URL of the file to download
+ * @returns The file data as `Promise
+ * @throws {WidgetApiActionError} If the widget API does not support the downloadFile action
+ */
+const tryDownloadFileWithWidgetApi = async (
+ widgetApi: WidgetApi,
+ mxc: string,
+) => {
+ try {
+ const result = await widgetApi.downloadFile(mxc);
+ return result;
+ } catch (error) {
+ const logger = getLogger('ImageDisplay.downloadFile');
+ logger.error('Failed to download image from widget api:', error);
+ throw new WidgetApiActionError('downloadFile not available');
+ }
+};
+
+/**
+ * Try getting an http url for the given mxc url.
+ *
+ * @param mxc The mxc URL of the file to download
+ * @param baseUrl The base URL of the matrix homeserver
+ * @param mimeType The MIME type of the file
+ * @returns The URL of the file. Note this is an blob URL for SVG files.
+ */
+const tryFallbackDownload = async (
+ mxc: string,
+ baseUrl: string,
+ mimeType: string,
+) => {
+ const httpUrl = convertMxcToHttpUrl(mxc, baseUrl);
+
+ if (httpUrl === null) {
+ return '';
+ }
+
+ if (mimeType === 'image/svg+xml') {
+ try {
+ const response = await fetch(httpUrl);
+ const rawBlob = await response.blob();
+ const svgBlob = rawBlob.slice(0, rawBlob.size, mimeType);
+ return svgBlob;
+ } catch (fetchError) {
+ const logger = getLogger('ImageDisplay.tryFallbackDownload');
+ logger.error('Failed to fetch SVG image:', fetchError);
+ throw new Error('Failed to fetch SVG image');
+ }
+ }
+ return httpUrl;
+};
+
type ImageDisplayProps = Omit &
WithExtendedSelectionProps & {
/**
@@ -37,13 +137,12 @@ type ImageDisplayProps = Omit &
baseUrl: string;
};
-const Image = styled('image', {
- shouldForwardProp: (p) => p !== 'loading',
-})<{ loading: boolean }>(({ loading }) => ({
+const Image = styled(
+ 'image',
+ {},
+)<{}>(() => ({
userSelect: 'none',
WebkitUserSelect: 'none',
- // prevention of partial rendering if the image has not yet been fully loaded
- visibility: loading ? 'hidden' : 'visible',
}));
/**
@@ -64,132 +163,34 @@ function ImageDisplay({
overrides = {},
}: ImageDisplayProps) {
const widgetApi = useWidgetApi();
- const [loadError, setLoadError] = useState(false);
- const [loading, setLoading] = useState(true);
- const [imageUri, setImageUri] = useState();
-
- const handleLoad = useCallback(() => {
- setLoading(false);
- setLoadError(false);
-
- // This can happen directly when the image is loaded and saves some memory.
- if (imageUri) {
- URL.revokeObjectURL(imageUri);
- }
- }, [setLoading, setLoadError, imageUri]);
-
- const handleLoadError = useCallback(() => {
- setLoading(false);
- setLoadError(true);
- }, [setLoading, setLoadError]);
+ const [imageUri, setImageUri] = useState();
+ const { data: image } = useSWR(
+ { widgetApi, baseUrl, mxc, mimeType },
+ downloadFile,
+ { suspense: true },
+ );
useEffect(() => {
- const downloadFile = async () => {
- try {
- const result = await tryDownloadFileWithWidgetApi(mxc);
- const blob = getBlobFromResult(result, mimeType);
- const downloadedFileDataUrl = createObjectUrlFromBlob(blob);
- setImageUri(downloadedFileDataUrl);
- } catch (error) {
- handleDownloadError(error as Error);
- }
- };
-
- const tryDownloadFileWithWidgetApi = async (mxc: string) => {
- try {
- const result = await widgetApi.downloadFile(mxc);
- return result;
- } catch {
- throw new WidgetApiActionError('downloadFile not available');
- }
- };
-
- const getBlobFromResult = (
- result: IDownloadFileActionFromWidgetResponseData,
- mimeType: string,
- ): Blob => {
- if (!(result.file instanceof Blob)) {
- throw new Error('Got non Blob file response');
- }
- return result.file.slice(0, result.file.size, mimeType);
- };
-
- const createObjectUrlFromBlob = (blob: Blob): string => {
- const url = URL.createObjectURL(blob);
- if (url === '') {
+ if (image instanceof Blob) {
+ // Convert blob to blob URL
+ const downloadedFileDataUrl = URL.createObjectURL(image);
+ if (downloadedFileDataUrl === '') {
throw new Error('Failed to create object URL');
}
- return url;
- };
-
- const handleDownloadError = (error: Error) => {
- if (error instanceof WidgetApiActionError) {
- tryFallbackDownload();
- } else {
- setLoadError(true);
- }
- };
-
- const tryFallbackDownload = async () => {
- let abortController: AbortController | undefined;
-
- const httpUrl = convertMxcToHttpUrl(mxc, baseUrl);
-
- if (httpUrl === null) {
- setImageUri('');
- return;
- }
-
- if (mimeType === 'image/svg+xml') {
- abortController = new AbortController();
- try {
- const response = await fetch(httpUrl, {
- signal: abortController.signal,
- });
- const rawBlob = await response.blob();
- const svgBlob = rawBlob.slice(0, rawBlob.size, mimeType);
- setImageUri(URL.createObjectURL(svgBlob));
- } catch (fetchError) {
- console.error('Failed to fetch SVG image:', fetchError);
- setLoadError(true);
- }
- return;
- }
-
- setImageUri(httpUrl);
-
- return () => {
- if (abortController) {
- abortController.abort();
- }
- };
- };
-
- downloadFile();
- }, [baseUrl, mimeType, mxc, widgetApi]);
-
- const renderedSkeleton =
- loading && !loadError ? (
-
- ) : null;
+ setImageUri(downloadedFileDataUrl);
+ } else {
+ setImageUri(image);
+ }
+ }, [image, setImageUri]);
- const renderedPlaceholder = loadError ? (
-
- ) : null;
+ const onLoaded = useCallback(() => {
+ if (image instanceof Blob && imageUri) {
+ URL.revokeObjectURL(imageUri);
+ }
+ }, [image, imageUri]);
const renderedChild =
- imageUri !== undefined && !loadError ? (
+ imageUri !== undefined ? (
) : null;
if (readOnly) {
- return (
- <>
- {renderedSkeleton} {renderedChild} {renderedPlaceholder}
- >
- );
+ return <>{renderedChild}>;
}
return (
@@ -221,9 +216,7 @@ function ImageDisplay({
>
- {renderedSkeleton}
{renderedChild}
- {renderedPlaceholder}
@@ -231,4 +224,50 @@ function ImageDisplay({
);
}
-export default React.memo(ImageDisplay);
+function ImageDisplayWrapper({
+ element,
+ otherProps,
+}: {
+ element: ImageElement;
+ otherProps: OtherProps;
+}) {
+ const widgetApi = useWidgetApi();
+
+ if (widgetApi.widgetParameters.baseUrl === undefined) {
+ console.error('Image cannot be rendered due to missing base URL');
+ return null;
+ }
+
+ return (
+
+ }
+ >
+
+ }
+ >
+
+
+
+ );
+}
+
+export default ImageDisplayWrapper;
diff --git a/yarn.lock b/yarn.lock
index 8a5e8c63..7df91eb8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2333,6 +2333,11 @@ cli-truncate@^4.0.0:
slice-ansi "^5.0.0"
string-width "^7.0.0"
+client-only@^0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
+ integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
+
cliui@^7.0.2:
version "7.0.4"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
@@ -5211,6 +5216,11 @@ picocolors@^1.0.0, picocolors@^1.1.0:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59"
integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==
+picocolors@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+ integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
@@ -5248,7 +5258,16 @@ possible-typed-array-names@^1.0.0:
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f"
integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==
-postcss@^8.2.14, postcss@^8.4.43, postcss@^8.4.44:
+postcss@^8.2.14:
+ version "8.4.49"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
+ integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
+ dependencies:
+ nanoid "^3.3.7"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
+postcss@^8.4.43, postcss@^8.4.44:
version "8.4.47"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365"
integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==
@@ -5391,6 +5410,13 @@ react-error-boundary@^3.1.4:
dependencies:
"@babel/runtime" "^7.12.5"
+react-error-boundary@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.1.2.tgz#bc750ad962edb8b135d6ae922c046051eb58f289"
+ integrity sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+
react-floater@^0.7.9:
version "0.7.9"
resolved "https://registry.yarnpkg.com/react-floater/-/react-floater-0.7.9.tgz#b15a652e817f200bfa42a2023ee8d3105803b968"
@@ -6270,6 +6296,14 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+swr@^2.2.5:
+ version "2.2.5"
+ resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.5.tgz#063eea0e9939f947227d5ca760cc53696f46446b"
+ integrity sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==
+ dependencies:
+ client-only "^0.0.1"
+ use-sync-external-store "^1.2.0"
+
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@@ -6624,7 +6658,7 @@ use-memo-one@^1.1.1:
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==
-use-sync-external-store@^1.0.0:
+use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"
integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==