Skip to content

Commit

Permalink
fix(ConfigProvider) Allow duplicated classes in the body created by C…
Browse files Browse the repository at this point in the history
…onfigProvider

resolves: #5518 

Для того чтобы независимые приложения не удаляли класс главного приложений из body при размонтировании мы позволяем каждому приложению на странице добавить свой класс в body с помощью `ConfigProvider`, даже если такой класс в body уже есть. Таким образом при размонтировании приложение будет удалять только свой класс, не нарушая работу остальных приложений.

Работа с классами `body` ведётся с помощь `className` а не `classList` (который не допускает дубликатов).
  • Loading branch information
mendrew authored Aug 18, 2023
2 parents df540cb + f47870a commit 65c57da
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useContext } from 'react';
import { render } from '@testing-library/react';
import React, { useContext, useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Appearance } from '../../helpers/appearance';
import { generateVKUITokensClassName } from '../../helpers/generateVKUITokensClassName';
import { Platform } from '../../lib/platform';
import { baselineComponent } from '../../testing/utils';
import { ConfigProvider } from './ConfigProvider';
Expand Down Expand Up @@ -130,4 +131,72 @@ describe('ConfigProvider', () => {
expect(config).toEqual(expect.objectContaining({ ...defaultConfig, [prop]: value }));
});
});

it('adds VKUITokenClassName to document.body on mount and removed on unmount', async () => {
const config = { appearance: Appearance.LIGHT, platform: Platform.VKCOM };
const ConfigUser = () => {
return null;
};
const { unmount } = render(
<ConfigProvider {...config}>
<ConfigUser />
</ConfigProvider>,
);

const vkuiBodySelector = generateVKUITokensClassName(config.platform, config.appearance);

expect(document.querySelector(`body.${vkuiBodySelector}`)).toBeTruthy();

unmount();

// removed on unmount
expect(document.querySelector(`body.${vkuiBodySelector}`)).toBeFalsy();
});

it('adds VKUITokenClassName to document.body on mount and not removes if child ConfigProvider is unmounted', async () => {
const config = { appearance: Appearance.LIGHT, platform: Platform.VKCOM };
const ConfigUser = () => {
return <div>User config</div>;
};

const ConfigUserWithOwnProvider = () => {
return (
<ConfigProvider {...config}>
<ConfigUser />
</ConfigProvider>
);
};

const TestComponent = () => {
const [isMounted, setIsMounted] = useState(true);

return (
<ConfigProvider {...config}>
<div>
<button onClick={() => setIsMounted(false)}>Unmount child context</button>
{isMounted && <ConfigUserWithOwnProvider />}
</div>
</ConfigProvider>
);
};

const { unmount } = render(<TestComponent />);

const vkuiBodySelector = generateVKUITokensClassName(config.platform, config.appearance);

// class name is applied to body
expect(document.querySelector(`body.${vkuiBodySelector}`)).toBeTruthy();

// unmount child ConfigProvider
fireEvent.click(screen.getByRole('button'));

// class from body is not removed on unmount of child context
expect(document.querySelector(`body.${vkuiBodySelector}`)).toBeTruthy();

// unmount parent context as well
unmount();

// when last context that is using this class is unmounted the class is removed from body
expect(document.querySelector(`body.${vkuiBodySelector}`)).toBeFalsy();
});
});
24 changes: 15 additions & 9 deletions packages/vkui/src/components/ConfigProvider/ConfigProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useObjectMemo } from '../../hooks/useObjectMemo';
import { useDOM } from '../../lib/dom';
import { TokensClassProvider } from '../../lib/tokensClassProvider';
import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect';
import { addClassNameToElement, removeClassNameFromElement } from '../../lib/utils';
import { warnOnce } from '../../lib/warnOnce';
import {
ConfigProviderContext,
Expand Down Expand Up @@ -80,17 +81,22 @@ ${webviewTypeRule}

const { document } = useDOM();

useIsomorphicLayoutEffect(() => {
const VKUITokensClassName = generateVKUITokensClassName(platform, appearance);
// TODO [>=6]: переместить хук в AppRoot (см. https://github.com/VKCOM/VKUI/issues/4810).
useIsomorphicLayoutEffect(
function attachVKUITokensClassNameToBody() {
if (!document) {
return;
}

// eslint-disable-next-line no-restricted-properties
document!.body.classList.add(VKUITokensClassName);
const VKUITokensClassName = generateVKUITokensClassName(platform, appearance);

return () => {
// eslint-disable-next-line no-restricted-properties
document!.body.classList.remove(VKUITokensClassName);
};
}, [platform, appearance]);
addClassNameToElement(document.body, VKUITokensClassName);
return () => {
removeClassNameFromElement(document.body, VKUITokensClassName);
};
},
[platform, appearance],
);

const configContext = useObjectMemo({
webviewType,
Expand Down
49 changes: 49 additions & 0 deletions packages/vkui/src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { addClassNameToElement, removeClassNameFromElement } from './utils';

describe('addClassNameToElement', () => {
test('adds className to element', () => {
const div = document.createElement('div');
addClassNameToElement(div, 'a-class');

expect(div.getAttribute('class')).toEqual('a-class');

// allows to add duplicated class name
addClassNameToElement(div, 'a-class');
expect(div.getAttribute('class')).toEqual('a-class a-class');

addClassNameToElement(div, 'b-class');
expect(div.getAttribute('class')).toEqual('a-class a-class b-class');
});
});

describe('removeClassNameFromElement', () => {
test('removes className from element', () => {
const div = document.createElement('div');
div.setAttribute('class', 'a-class');

removeClassNameFromElement(div, 'a-class');
expect(div.getAttribute('class')).toEqual('');

// allows to remove duplicated class name
div.setAttribute('class', 'a-class b-class a-class');

// remove not existing class
removeClassNameFromElement(div, 'unknown-class');
expect(div.getAttribute('class')).toEqual('a-class b-class a-class');

removeClassNameFromElement(div, 'a-class');
expect(div.getAttribute('class')).toEqual('b-class a-class');

// allows to remove duplicated class name
div.setAttribute('class', 'a-class b-class a-class');

removeClassNameFromElement(div, 'b-class');
expect(div.getAttribute('class')).toEqual('a-class a-class');

removeClassNameFromElement(div, 'a-class');
expect(div.getAttribute('class')).toEqual('a-class');

removeClassNameFromElement(div, 'a-class');
expect(div.getAttribute('class')).toEqual('');
});
});
20 changes: 20 additions & 0 deletions packages/vkui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,23 @@ export function getTitleFromChildren(children: React.ReactNode): string {

export const stopPropagation = <T extends React.SyntheticEvent>(event: T) =>
event.stopPropagation();

export function addClassNameToElement(element: HTMLElement, className: string) {
const elementClassName = element.getAttribute('class') || '';
const updatedClassName = `${elementClassName}${elementClassName ? ' ' : ''}${className}`;

element.setAttribute('class', updatedClassName);
}

export function removeClassNameFromElement(element: HTMLElement, classNameToRemove: string) {
const classNamesArray = (element.getAttribute('class') || '').split(/\s+/);
const elementIndexToRemove = classNamesArray.findIndex(
(className) => className === classNameToRemove,
);
if (elementIndexToRemove === -1) {
return;
}
classNamesArray.splice(elementIndexToRemove, 1);

element.setAttribute('class', classNamesArray.join(' '));
}

0 comments on commit 65c57da

Please sign in to comment.