Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cdn-icon): fix icon update when name changes #1558

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

spivakev
Copy link

@spivakev spivakev commented Jan 31, 2025

Опишите проблему

При изменении пропса name CDNIсon не перерисовывает иконку

Шаги для воспроизведения

  1. Перейти в песочницу https://core-ds.github.io/core-components/master/?path=/docs/sandbox--docs
  2. Вставить следующий пример:
function Example() {
    const [name, setName] = React.useState('glyph_airplane_m');
    return (
        <div>
            <CDNIcon name={name}/>
            <Gap size={16}/>
            <Button onClick={() => setName('glyph_diamonds_m')}>
                diamond icon
            </Button>
            <Gap size={16}/>
            <Button onClick={() => setName('glyph_apelsin_m_color')}>
                orange icon
            </Button>
        </div>
    );
}
render(
    <Example />
)
  1. Нажатием кнопок попробовать сменить иконку

Ожидаемое поведение

  1. По клику на кнопку иконка меняется на соответствующую
  2. При повторном клике в дальнейшем на эту же кнопку, запрос не вызывается, но иконка меняется (работает кеширование)

Copy link

changeset-bot bot commented Jan 31, 2025

🦋 Changeset detected

Latest commit: 0ef81d7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@alfalab/core-components-cdn-icon Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coveralls
Copy link

coveralls commented Jan 31, 2025

Pull Request Test Coverage Report for Build 13201798830

Details

  • 2 of 4 (50.0%) changed or added relevant lines in 1 file are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-0.004%) to 82.347%

Changes Missing Coverage Covered Lines Changed/Added Lines %
packages/cdn-icon/src/Component.tsx 2 4 50.0%
Totals Coverage Status
Change from base Build 13201291168: -0.004%
Covered Lines: 10815
Relevant Lines: 11986

💛 - Coveralls

@core-ds-bot
Copy link
Collaborator

Собрана новая демка.

@core-ds-bot
Copy link
Collaborator

Собрана новая демка.

@core-ds-bot
Copy link
Collaborator

Собрана новая демка.

@@ -53,11 +53,18 @@ export const CDNIcon: React.FC<CDNIconProps> = ({

const [loadingStatus, setLoadingStatus] = useState<LoadingStatus>(LoadingStatus.INITIAL);
const [icon, setIcon] = useState(cache[url]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

здесь комплексная проблема

  • icon должен быть derived (computed) state из cache и url, то есть не хранится отдельно
  • в таком случае cache должно стать состоянием и тут на помощь приходит useSyncExternalStore

поэтому предлагаю сделать обертку use-cache.ts по аналогии с todosStore.js из ссылки выше

import { useSyncExternalStore } from 'react';

type Listener = () => void;

// Кэшируем загруженные иконки, чтобы предотвратить их повторную загрузку при каждом монтировании
let cache: Record<string, string> = {};
let listeners: Listener[] = [];

const cacheStore = {
    set(url: string, icon: string) {
        cache = { ...cache, [url]: icon };

        listeners.forEach((listener) => {
            listener();
        });
    },
    subscribe(listener: Listener) {
        listeners = [...listeners, listener];

        return () => {
            listeners = listeners.filter((l) => l !== listener);
        };
    },
    getSnapshot() {
        return cache;
    },
};

export function useCache() {
    return [
        useSyncExternalStore(cacheStore.subscribe, cacheStore.getSnapshot),
        cacheStore.set,
    ] as const;
}

и соответствующе исправить Component.tsx

import React, { ReactNode, useEffect, useState } from 'react';
import cn from 'classnames';

import { useCache } from './use-cache';

import styles from './index.module.css';

type CDNIconProps = {
    /**
     * Имя иконки
     */
    name: string;
    /**
     * Цвет иконки
     */
    color?: string;
    /**
     * Дополнительный класс
     */
    className?: string;
    /**
     * Базовый адрес cdn хранилища c иконками
     * @default https://alfabank.servicecdn.ru/icons
     */
    baseUrl?: string;
    /**
     * Идентификатор для систем автоматизированного тестирования
     */
    dataTestId?: string;

    /**
     * Fallback на случай, если не удастся загрузить иконку
     */
    fallback?: ReactNode;
};

enum LoadingStatus {
    INITIAL,
    SUCCESS,
    FAILURE,
}

export const CDNIcon: React.FC<CDNIconProps> = ({
    name,
    color,
    dataTestId,
    className,
    baseUrl = 'https://alfabank.servicecdn.ru/icons',
    fallback,
}) => {
    const url = `${baseUrl}/${name}.svg`;

    const [loadingStatus, setLoadingStatus] = useState<LoadingStatus>(LoadingStatus.INITIAL);
    const [cache, setIcon] = useCache();
    const icon = cache[url];

    const monoIcon = !name.includes('_color');

    useEffect(() => {
        if (icon) return undefined;

        const xhr = new XMLHttpRequest();

        xhr.open('GET', url);
        xhr.send();
        xhr.onload = function onload() {
            setLoadingStatus(LoadingStatus.SUCCESS);
            const svg = xhr.response;

            if (svg.startsWith('<svg')) {
                setIcon(url, svg);
            }
        };

        xhr.onerror = function onError() {
            setLoadingStatus(LoadingStatus.FAILURE);
        };

        return () => xhr.abort();
    }, [url, icon, setIcon]);

    return (
        <span
            style={{ color }}
            className={cn('cc-cdn-icon', styles.component, className, {
                [styles.parentColor]: monoIcon,
            })}
            data-test-id={dataTestId}
            {...(loadingStatus === LoadingStatus.FAILURE
                ? { children: fallback }
                : { dangerouslySetInnerHTML: { __html: icon } })}
        />
    );
};

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

немного подправил код)

use-icon.ts

import { useEffect, useState, useSyncExternalStore } from 'react';

import { hasOwnProperty, noop } from '@alfalab/core-components-shared';

type Listener = () => void;

export enum LoadingStatus {
    INITIAL,
    SUCCESS,
    FAILURE,
}

// Кэшируем загруженные иконки, чтобы предотвратить их повторную загрузку при каждом монтировании
let cache: Record<string, string | undefined> = {};
let listeners: Listener[] = [];

const iconsStore = {
    set(url: string, icon: string) {
        cache = { ...cache, [url]: icon };

        listeners.forEach((listener) => {
            listener();
        });
    },
    has(url: string) {
        return hasOwnProperty(cache, url);
    },
    subscribe(listener: Listener) {
        listeners = [...listeners, listener];

        return () => {
            listeners = listeners.filter((l) => l !== listener);
        };
    },
    getSnapshot() {
        return cache;
    },
};

export function useIcon(url: string): [icon: string | undefined, status: LoadingStatus] {
    const icons = useSyncExternalStore(iconsStore.subscribe, iconsStore.getSnapshot);
    const [loadingStatus, setLoadingStatus] = useState(LoadingStatus.INITIAL);

    useEffect(() => {
        if (iconsStore.has(url)) {
            setLoadingStatus(LoadingStatus.SUCCESS);

            return noop;
        }

        setLoadingStatus(LoadingStatus.INITIAL);

        const xhr = new XMLHttpRequest();

        xhr.open('GET', url);
        xhr.send();
        xhr.onload = function onload() {
            const svg = xhr.response;

            if (typeof svg === 'string' && svg.startsWith('<svg')) {
                iconsStore.set(url, svg);
            }
            setLoadingStatus(LoadingStatus.SUCCESS);
        };

        xhr.onerror = function onError() {
            setLoadingStatus(LoadingStatus.FAILURE);
        };

        return () => xhr.abort();
    }, [url]);

    return [icons[url], loadingStatus];
}

и код компонента Component.tsx

import React, { ReactNode } from 'react';
import cn from 'classnames';

import { LoadingStatus, useIcon } from './use-icon';

import styles from './index.module.css';

type CDNIconProps = {
    /**
     * Имя иконки
     */
    name: string;
    /**
     * Цвет иконки
     */
    color?: string;
    /**
     * Дополнительный класс
     */
    className?: string;
    /**
     * Базовый адрес cdn хранилища c иконками
     * @default https://alfabank.servicecdn.ru/icons
     */
    baseUrl?: string;
    /**
     * Идентификатор для систем автоматизированного тестирования
     */
    dataTestId?: string;

    /**
     * Fallback на случай, если не удастся загрузить иконку
     */
    fallback?: ReactNode;
};

export const CDNIcon: React.FC<CDNIconProps> = ({
    name,
    color,
    dataTestId,
    className,
    baseUrl = 'https://alfabank.servicecdn.ru/icons',
    fallback,
}) => {
    const [icon, status] = useIcon(`${baseUrl}/${name}.svg`);

    const monoIcon = !name.includes('_color');

    return (
        <span
            style={{ color }}
            className={cn('cc-cdn-icon', styles.component, className, {
                [styles.parentColor]: monoIcon,
            })}
            data-test-id={dataTestId}
            // eslint-disable-next-line react/no-danger
            dangerouslySetInnerHTML={
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                status === LoadingStatus.SUCCESS ? { __html: icon! } : undefined
            }
        >
            {status === LoadingStatus.FAILURE ? fallback : undefined}
        </span>
    );
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants