diff --git a/modules/react-mapbox/src/utils/apply-react-style.ts b/modules/react-mapbox/src/utils/apply-react-style.ts index 2ff1b9b64..5ca3dd5d0 100644 --- a/modules/react-mapbox/src/utils/apply-react-style.ts +++ b/modules/react-mapbox/src/utils/apply-react-style.ts @@ -3,18 +3,59 @@ import * as React from 'react'; // https://github.com/facebook/react/blob/4131af3e4bf52f3a003537ec95a1655147c81270/src/renderers/dom/shared/CSSPropertyOperations.js#L62 const unitlessNumber = /box|flex|grid|column|lineHeight|fontWeight|opacity|order|tabSize|zIndex/; -export function applyReactStyle(element: HTMLElement, styles: React.CSSProperties) { - if (!element || !styles) { +// Remembers the style keys applied to each element on the previous call, so that +// properties which are later removed (set to undefined/null or omitted from the +// style object) can be unset instead of lingering on the element. +const appliedStyleKeys = new WeakMap>(); + +function getDefinedStyleKeys(styles: React.CSSProperties): Set { + const keys = new Set(); + for (const key in styles) { + if (styles[key] !== undefined && styles[key] !== null) { + keys.add(key); + } + } + return keys; +} + +function setStyleValue(style: CSSStyleDeclaration, key: string, value) { + if (Number.isFinite(value) && !unitlessNumber.test(key)) { + style[key] = `${value}px`; + } else { + style[key] = value; + } +} + +export function applyReactStyle( + element: HTMLElement | null | undefined, + styles: React.CSSProperties | null | undefined +) { + if (!element) { return; } const style = element.style; + const nextStyles = styles ?? {}; + const previousKeys = appliedStyleKeys.get(element); + const nextKeys = getDefinedStyleKeys(nextStyles); - for (const key in styles) { - const value = styles[key]; - if (Number.isFinite(value) && !unitlessNumber.test(key)) { - style[key] = `${value}px`; - } else { - style[key] = value; + if (previousKeys) { + for (const key of previousKeys) { + if (!nextKeys.has(key)) { + style[key] = ''; + } } } + + for (const key in nextStyles) { + const value = nextStyles[key]; + if (value !== undefined && value !== null) { + setStyleValue(style, key, value); + } + } + + if (nextKeys.size) { + appliedStyleKeys.set(element, nextKeys); + } else { + appliedStyleKeys.delete(element); + } } diff --git a/modules/react-mapbox/test/utils/apply-react-style.spec.js b/modules/react-mapbox/test/utils/apply-react-style.spec.js index d246dcb71..8923036d2 100644 --- a/modules/react-mapbox/test/utils/apply-react-style.spec.js +++ b/modules/react-mapbox/test/utils/apply-react-style.spec.js @@ -24,3 +24,73 @@ test('applyReactStyle', t => { t.end(); }); + +test('applyReactStyle#unset removed properties', t => { + /* global document */ + if (typeof document === 'undefined') { + t.end(); + return; + } + + const div = document.createElement('div'); + + applyReactStyle(div, {background: 'red', color: 'blue', borderColor: 'green'}); + applyReactStyle(div, {background: undefined, color: 'blue', borderColor: null}); + t.is(div.style.background, '', 'unsets a property that became undefined'); + t.is(div.style.borderColor, '', 'unsets a property that became null'); + t.is(div.style.color, 'blue', 'keeps a property that is still set'); + + applyReactStyle(div, {}); + t.is(div.style.color, '', 'unsets a property omitted from the style object'); + + applyReactStyle(div, {background: 'green'}); + t.is(div.style.background, 'green', 'reapplies a property after it was cleared'); + + applyReactStyle(div, undefined); + t.is(div.style.background, '', 'clears applied properties when styles become undefined'); + + applyReactStyle(div, {color: 'purple'}); + applyReactStyle(div, null); + t.is(div.style.color, '', 'clears applied properties when styles become null'); + + t.end(); +}); + +test('applyReactStyle#handles shorthand transitions', t => { + /* global document */ + if (typeof document === 'undefined') { + t.end(); + return; + } + + const shorthandToLonghand = document.createElement('div'); + applyReactStyle(shorthandToLonghand, {padding: '10px'}); + applyReactStyle(shorthandToLonghand, {paddingTop: '20px'}); + t.is(shorthandToLonghand.style.paddingTop, '20px', 'preserves a new longhand property'); + t.is(shorthandToLonghand.style.paddingRight, '', 'clears the removed shorthand property'); + + const longhandToShorthand = document.createElement('div'); + applyReactStyle(longhandToShorthand, {paddingTop: '20px'}); + applyReactStyle(longhandToShorthand, {padding: '10px'}); + t.is(longhandToShorthand.style.paddingTop, '10px', 'preserves a new shorthand property'); + t.is(longhandToShorthand.style.paddingRight, '10px', 'applies all parts of the shorthand'); + + t.end(); +}); + +test('applyReactStyle#preserves unmanaged inline properties', t => { + /* global document */ + if (typeof document === 'undefined') { + t.end(); + return; + } + + const div = document.createElement('div'); + div.style.opacity = '0.5'; + + applyReactStyle(div, {color: 'red'}); + applyReactStyle(div, {}); + + t.is(div.style.opacity, '0.5', 'does not clear properties it did not apply'); + t.end(); +}); diff --git a/modules/react-maplibre/src/utils/apply-react-style.ts b/modules/react-maplibre/src/utils/apply-react-style.ts index 2ff1b9b64..5ca3dd5d0 100644 --- a/modules/react-maplibre/src/utils/apply-react-style.ts +++ b/modules/react-maplibre/src/utils/apply-react-style.ts @@ -3,18 +3,59 @@ import * as React from 'react'; // https://github.com/facebook/react/blob/4131af3e4bf52f3a003537ec95a1655147c81270/src/renderers/dom/shared/CSSPropertyOperations.js#L62 const unitlessNumber = /box|flex|grid|column|lineHeight|fontWeight|opacity|order|tabSize|zIndex/; -export function applyReactStyle(element: HTMLElement, styles: React.CSSProperties) { - if (!element || !styles) { +// Remembers the style keys applied to each element on the previous call, so that +// properties which are later removed (set to undefined/null or omitted from the +// style object) can be unset instead of lingering on the element. +const appliedStyleKeys = new WeakMap>(); + +function getDefinedStyleKeys(styles: React.CSSProperties): Set { + const keys = new Set(); + for (const key in styles) { + if (styles[key] !== undefined && styles[key] !== null) { + keys.add(key); + } + } + return keys; +} + +function setStyleValue(style: CSSStyleDeclaration, key: string, value) { + if (Number.isFinite(value) && !unitlessNumber.test(key)) { + style[key] = `${value}px`; + } else { + style[key] = value; + } +} + +export function applyReactStyle( + element: HTMLElement | null | undefined, + styles: React.CSSProperties | null | undefined +) { + if (!element) { return; } const style = element.style; + const nextStyles = styles ?? {}; + const previousKeys = appliedStyleKeys.get(element); + const nextKeys = getDefinedStyleKeys(nextStyles); - for (const key in styles) { - const value = styles[key]; - if (Number.isFinite(value) && !unitlessNumber.test(key)) { - style[key] = `${value}px`; - } else { - style[key] = value; + if (previousKeys) { + for (const key of previousKeys) { + if (!nextKeys.has(key)) { + style[key] = ''; + } } } + + for (const key in nextStyles) { + const value = nextStyles[key]; + if (value !== undefined && value !== null) { + setStyleValue(style, key, value); + } + } + + if (nextKeys.size) { + appliedStyleKeys.set(element, nextKeys); + } else { + appliedStyleKeys.delete(element); + } } diff --git a/modules/react-maplibre/test/utils/apply-react-style.spec.js b/modules/react-maplibre/test/utils/apply-react-style.spec.js index 6250b2d01..440d05fe8 100644 --- a/modules/react-maplibre/test/utils/apply-react-style.spec.js +++ b/modules/react-maplibre/test/utils/apply-react-style.spec.js @@ -24,3 +24,73 @@ test('applyReactStyle', t => { t.end(); }); + +test('applyReactStyle#unset removed properties', t => { + /* global document */ + if (typeof document === 'undefined') { + t.end(); + return; + } + + const div = document.createElement('div'); + + applyReactStyle(div, {background: 'red', color: 'blue', borderColor: 'green'}); + applyReactStyle(div, {background: undefined, color: 'blue', borderColor: null}); + t.is(div.style.background, '', 'unsets a property that became undefined'); + t.is(div.style.borderColor, '', 'unsets a property that became null'); + t.is(div.style.color, 'blue', 'keeps a property that is still set'); + + applyReactStyle(div, {}); + t.is(div.style.color, '', 'unsets a property omitted from the style object'); + + applyReactStyle(div, {background: 'green'}); + t.is(div.style.background, 'green', 'reapplies a property after it was cleared'); + + applyReactStyle(div, undefined); + t.is(div.style.background, '', 'clears applied properties when styles become undefined'); + + applyReactStyle(div, {color: 'purple'}); + applyReactStyle(div, null); + t.is(div.style.color, '', 'clears applied properties when styles become null'); + + t.end(); +}); + +test('applyReactStyle#handles shorthand transitions', t => { + /* global document */ + if (typeof document === 'undefined') { + t.end(); + return; + } + + const shorthandToLonghand = document.createElement('div'); + applyReactStyle(shorthandToLonghand, {padding: '10px'}); + applyReactStyle(shorthandToLonghand, {paddingTop: '20px'}); + t.is(shorthandToLonghand.style.paddingTop, '20px', 'preserves a new longhand property'); + t.is(shorthandToLonghand.style.paddingRight, '', 'clears the removed shorthand property'); + + const longhandToShorthand = document.createElement('div'); + applyReactStyle(longhandToShorthand, {paddingTop: '20px'}); + applyReactStyle(longhandToShorthand, {padding: '10px'}); + t.is(longhandToShorthand.style.paddingTop, '10px', 'preserves a new shorthand property'); + t.is(longhandToShorthand.style.paddingRight, '10px', 'applies all parts of the shorthand'); + + t.end(); +}); + +test('applyReactStyle#preserves unmanaged inline properties', t => { + /* global document */ + if (typeof document === 'undefined') { + t.end(); + return; + } + + const div = document.createElement('div'); + div.style.opacity = '0.5'; + + applyReactStyle(div, {color: 'red'}); + applyReactStyle(div, {}); + + t.is(div.style.opacity, '0.5', 'does not clear properties it did not apply'); + t.end(); +});