Skip to content

Commit a5c0f6d

Browse files
fix(useStylesheet): track stylesheets with useSyncExternalStore (#5845)
1 parent bf44cf0 commit a5c0f6d

File tree

8 files changed

+110
-67
lines changed

8 files changed

+110
-67
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@types/node": "^20.0.0",
6060
"@types/react": "18.3.2",
6161
"@types/react-dom": "^18.2.22",
62+
"@types/use-sync-external-store": "^0.0.6",
6263
"@typescript-eslint/eslint-plugin": "^7.0.0",
6364
"@typescript-eslint/parser": "^7.0.0",
6465
"@ui5/webcomponents-tools": "1.24.2",

packages/base/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,8 @@
5555
"LICENSE",
5656
"NOTICE.txt",
5757
"README.md"
58-
]
58+
],
59+
"dependencies": {
60+
"use-sync-external-store": "1.2.2"
61+
}
5962
}

packages/base/src/context/StyleContext.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

packages/base/src/hooks/useStylesheet.ts

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,25 @@
22

33
import type { StyleDataCSP } from '@ui5/webcomponents-base/dist/ManagedStyles.js';
44
import { createOrUpdateStyle, removeStyle } from '@ui5/webcomponents-base/dist/ManagedStyles.js';
5-
import * as React from 'react';
6-
import { useStyleContext } from '../context/StyleContext.js';
7-
8-
function getUseInsertionEffect(isSSR: boolean) {
9-
return isSSR ? React.useEffect : Reflect.get(React, 'useInsertionEffect') || React.useLayoutEffect;
10-
}
11-
12-
function trackComponentStyleMount(componentMap: Map<string, number>, componentName: string) {
13-
if (componentMap.has(componentName)) {
14-
componentMap.set(componentName, componentMap.get(componentName)! + 1);
15-
} else {
16-
componentMap.set(componentName, 1);
17-
}
18-
}
19-
20-
function trackComponentStyleUnmount(componentMap: Map<string, number>, componentName: string) {
21-
if (componentMap.has(componentName)) {
22-
componentMap.set(componentName, componentMap.get(componentName)! - 1);
23-
}
24-
}
5+
import { useSyncExternalStore } from 'use-sync-external-store/shim';
6+
import { StyleStore } from '../stores/StyleStore.js';
7+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect.js';
258

269
export function useStylesheet(styles: StyleDataCSP, componentName: string) {
27-
const styleContext = useStyleContext();
28-
const { staticCssInjected, componentsMap } = styleContext;
10+
const { staticCssInjected, componentsMap } = useSyncExternalStore(StyleStore.subscribe, StyleStore.getSnapshot);
2911

30-
getUseInsertionEffect(typeof window === 'undefined')(() => {
31-
if (!staticCssInjected) {
12+
useIsomorphicLayoutEffect(() => {
13+
const shouldInject = !staticCssInjected;
14+
if (shouldInject) {
3215
createOrUpdateStyle(styles, 'data-ui5wcr-component', componentName);
33-
trackComponentStyleMount(componentsMap, componentName);
16+
StyleStore.mountComponent(componentName);
3417
}
3518

3619
return () => {
37-
if (!staticCssInjected) {
38-
trackComponentStyleUnmount(componentsMap, componentName);
39-
const numberOfMountedComponents = componentsMap.get(componentName);
40-
if (typeof numberOfMountedComponents === 'number' && numberOfMountedComponents <= 0) {
20+
if (shouldInject) {
21+
StyleStore.unmountComponent(componentName);
22+
const numberOfMountedComponents = componentsMap.get(componentName)!;
23+
if (numberOfMountedComponents <= 0) {
4124
removeStyle('data-ui5wcr-component', componentName);
4225
}
4326
}

packages/base/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { getI18nContext, I18nContext } from './context/I18nContext.js';
2-
import { getStyleContext, useStyleContext } from './context/StyleContext.js';
32
import * as Device from './Device/index.js';
43
import * as hooks from './hooks/index.js';
4+
import { StyleStore } from './stores/StyleStore.js';
55
import * as spacing from './styling/spacing.js';
66
import { ThemingParameters } from './styling/ThemingParameters.js';
77

88
export * from './styling/CssSizeVariables.js';
99
export * from './utils/index.js';
1010
export * from './hooks/index.js';
1111

12-
export { getI18nContext, I18nContext, getStyleContext, useStyleContext, ThemingParameters, Device, hooks, spacing };
12+
export { getI18nContext, I18nContext, StyleStore, ThemingParameters, Device, hooks, spacing };
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const STORE_SYMBOL_LISTENERS = Symbol.for('@ui5/webcomponents-react/StyleStore/Listeners');
2+
const STORE_SYMBOL = Symbol.for('@ui5/webcomponents-react/StyleStore');
3+
4+
interface IStyleStore {
5+
staticCssInjected: boolean;
6+
componentsMap: Map<string, number>;
7+
}
8+
9+
const initialStore: IStyleStore = {
10+
staticCssInjected: false,
11+
componentsMap: new Map<string, number>()
12+
};
13+
14+
function getListeners(): Array<() => void> {
15+
globalThis[STORE_SYMBOL_LISTENERS] ??= [];
16+
return globalThis[STORE_SYMBOL_LISTENERS];
17+
}
18+
19+
function emitChange() {
20+
for (const listener of getListeners()) {
21+
listener();
22+
}
23+
}
24+
25+
function getSnapshot(): IStyleStore {
26+
globalThis[STORE_SYMBOL] ??= initialStore;
27+
return globalThis[STORE_SYMBOL];
28+
}
29+
30+
function subscribe(listener: () => void) {
31+
const listeners = getListeners();
32+
globalThis[STORE_SYMBOL_LISTENERS] = [...listeners, listener];
33+
return () => {
34+
globalThis[STORE_SYMBOL_LISTENERS] = listeners.filter((l) => l !== listener);
35+
};
36+
}
37+
38+
export const StyleStore = {
39+
subscribe,
40+
getSnapshot,
41+
setStaticCssInjected: (staticCssInjected: boolean) => {
42+
const curr = getSnapshot();
43+
globalThis[STORE_SYMBOL] = {
44+
...curr,
45+
staticCssInjected
46+
};
47+
emitChange();
48+
},
49+
mountComponent: (componentName: string) => {
50+
const { componentsMap } = getSnapshot();
51+
if (componentsMap.has(componentName)) {
52+
componentsMap.set(componentName, componentsMap.get(componentName)! + 1);
53+
} else {
54+
componentsMap.set(componentName, 1);
55+
}
56+
emitChange();
57+
},
58+
unmountComponent: (componentName: string) => {
59+
const { componentsMap } = getSnapshot();
60+
if (componentsMap.has(componentName)) {
61+
componentsMap.set(componentName, componentsMap.get(componentName)! - 1);
62+
}
63+
emitChange();
64+
}
65+
};

packages/main/src/components/ThemeProvider/index.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
import { getTheme } from '@ui5/webcomponents-base/dist/config/Theme.js';
44
import { attachThemeLoaded, detachThemeLoaded } from '@ui5/webcomponents-base/dist/theming/ThemeLoaded.js';
55
import {
6-
getStyleContext,
6+
StyleStore,
77
ThemingParameters,
88
useIsomorphicId,
99
useIsomorphicLayoutEffect,
1010
useStylesheet
1111
} from '@ui5/webcomponents-react-base';
1212
import type { FC, ReactNode } from 'react';
13-
import React, { useMemo } from 'react';
13+
import React from 'react';
1414
import { ThemeProvider as ReactJssThemeProvider } from 'react-jss';
1515
import { I18nProvider } from '../../internal/I18nProvider.js';
1616
import { ModalsProvider } from '../Modals/ModalsProvider.js';
@@ -45,7 +45,7 @@ export interface ThemeProviderPropTypes {
4545
* __Note:__ Per default, the `ThemeProvider` injects the CSS for the components during runtime. If you have imported our static CSS bundle/s in your application, you can set the prop `staticCssInjected` to `true` to prevent this.
4646
*/
4747
const ThemeProvider: FC<ThemeProviderPropTypes> = (props: ThemeProviderPropTypes) => {
48-
const { children, withoutModalsProvider, staticCssInjected } = props;
48+
const { children, withoutModalsProvider = false, staticCssInjected = false } = props;
4949

5050
useIsomorphicLayoutEffect(() => {
5151
document.documentElement.setAttribute('data-sap-theme', getTheme());
@@ -59,21 +59,17 @@ const ThemeProvider: FC<ThemeProviderPropTypes> = (props: ThemeProviderPropTypes
5959
};
6060
}, []);
6161

62-
const StyleContext = getStyleContext();
63-
const styleContextValue = useMemo(() => {
64-
return {
65-
staticCssInjected: staticCssInjected ?? false,
66-
componentsMap: new Map()
67-
};
62+
useIsomorphicLayoutEffect(() => {
63+
StyleStore.setStaticCssInjected(staticCssInjected);
6864
}, [staticCssInjected]);
6965

7066
return (
71-
<StyleContext.Provider value={styleContextValue}>
67+
<>
7268
<ThemeProviderStyles />
7369
<ReactJssThemeProvider theme={ThemingParameters}>
7470
<I18nProvider>{withoutModalsProvider ? children : <ModalsProvider>{children}</ModalsProvider>}</I18nProvider>
7571
</ReactJssThemeProvider>
76-
</StyleContext.Provider>
72+
</>
7773
);
7874
};
7975

yarn.lock

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6664,6 +6664,13 @@ __metadata:
66646664
languageName: node
66656665
linkType: hard
66666666

6667+
"@types/use-sync-external-store@npm:^0.0.6":
6668+
version: 0.0.6
6669+
resolution: "@types/use-sync-external-store@npm:0.0.6"
6670+
checksum: 10c0/77c045a98f57488201f678b181cccd042279aff3da34540ad242f893acc52b358bd0a8207a321b8ac09adbcef36e3236944390e2df4fcedb556ce7bb2a88f2a8
6671+
languageName: node
6672+
linkType: hard
6673+
66676674
"@types/uuid@npm:^9.0.1":
66686675
version: 9.0.7
66696676
resolution: "@types/uuid@npm:9.0.7"
@@ -7083,6 +7090,8 @@ __metadata:
70837090
"@ui5/webcomponents-react-base@workspace:packages/base, @ui5/webcomponents-react-base@workspace:~":
70847091
version: 0.0.0-use.local
70857092
resolution: "@ui5/webcomponents-react-base@workspace:packages/base"
7093+
dependencies:
7094+
use-sync-external-store: "npm:1.2.2"
70867095
peerDependencies:
70877096
"@types/react": "*"
70887097
"@ui5/webcomponents-base": ~1.24.0
@@ -22932,6 +22941,7 @@ __metadata:
2293222941
"@types/node": "npm:^20.0.0"
2293322942
"@types/react": "npm:18.3.2"
2293422943
"@types/react-dom": "npm:^18.2.22"
22944+
"@types/use-sync-external-store": "npm:^0.0.6"
2293522945
"@typescript-eslint/eslint-plugin": "npm:^7.0.0"
2293622946
"@typescript-eslint/parser": "npm:^7.0.0"
2293722947
"@ui5/webcomponents": "npm:1.24.2"
@@ -23267,6 +23277,15 @@ __metadata:
2326723277
languageName: node
2326823278
linkType: hard
2326923279

23280+
"use-sync-external-store@npm:1.2.2":
23281+
version: 1.2.2
23282+
resolution: "use-sync-external-store@npm:1.2.2"
23283+
peerDependencies:
23284+
react: ^16.8.0 || ^17.0.0 || ^18.0.0
23285+
checksum: 10c0/23b1597c10adf15b26ade9e8c318d8cc0abc9ec0ab5fc7ca7338da92e89c2536abd150a5891bf076836c352fdfa104fc7231fb48f806fd9960e0cbe03601abaf
23286+
languageName: node
23287+
linkType: hard
23288+
2327023289
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
2327123290
version: 1.0.2
2327223291
resolution: "util-deprecate@npm:1.0.2"

0 commit comments

Comments
 (0)