From fb15183accafff13dc35217a450e7f2c3c6eedcd Mon Sep 17 00:00:00 2001 From: Tobias Date: Wed, 14 Oct 2026 21:35:35 +0200 Subject: [PATCH 1/2] fix: re-render Layer and Source on styledata only when missing from the style `styledata` fires on every style change, such as addImage or setPaintProperty. Each event rendered every and again, although the render only does work when the layer or source is missing, for example after a style swap. Co-Authored-By: Claude Opus 5.5 --- modules/react-mapbox/src/components/layer.ts | 12 +++- modules/react-mapbox/src/components/source.ts | 12 +++- .../test/components/layer.spec.jsx | 53 ++++++++++++++++++ .../react-maplibre/src/components/layer.ts | 12 +++- .../react-maplibre/src/components/source.ts | 12 +++- .../test/components/layer.spec.jsx | 55 ++++++++++++++++++- 6 files changed, 147 insertions(+), 9 deletions(-) diff --git a/modules/react-mapbox/src/components/layer.ts b/modules/react-mapbox/src/components/layer.ts index 439be9c8d..830697cd7 100644 --- a/modules/react-mapbox/src/components/layer.ts +++ b/modules/react-mapbox/src/components/layer.ts @@ -92,11 +92,19 @@ export function Layer(props: LayerProps) { useEffect(() => { if (map) { const forceUpdate = () => setStyleLoaded(version => version + 1); - map.on('styledata', forceUpdate); + // `styledata` fires on every style change, e.g. addImage or setPaintProperty. + // Only re-render if the layer is missing, e.g. after the style was swapped + const onStyleData = () => { + // @ts-ignore + if (!(map.style && map.style._loaded && map.getLayer(id))) { + forceUpdate(); + } + }; + map.on('styledata', onStyleData); forceUpdate(); return () => { - map.off('styledata', forceUpdate); + map.off('styledata', onStyleData); // @ts-ignore if (map.style && map.style._loaded && map.getLayer(id)) { map.removeLayer(id); diff --git a/modules/react-mapbox/src/components/source.ts b/modules/react-mapbox/src/components/source.ts index 2e02c9740..5e0e1b184 100644 --- a/modules/react-mapbox/src/components/source.ts +++ b/modules/react-mapbox/src/components/source.ts @@ -90,11 +90,19 @@ export function Source(props: SourceProps) { if (map) { /* global setTimeout */ const forceUpdate = () => setTimeout(() => setStyleLoaded(version => version + 1), 0); - map.on('styledata', forceUpdate); + // `styledata` fires on every style change, e.g. addImage or setPaintProperty. + // Only re-render if the source is missing, e.g. after the style was swapped + const onStyleData = () => { + // @ts-ignore + if (!(map.style && map.style._loaded && map.getSource(id))) { + forceUpdate(); + } + }; + map.on('styledata', onStyleData); forceUpdate(); return () => { - map.off('styledata', forceUpdate); + map.off('styledata', onStyleData); // @ts-ignore if (map.style && map.style._loaded && map.getSource(id)) { // Parent effects are destroyed before child ones, see diff --git a/modules/react-mapbox/test/components/layer.spec.jsx b/modules/react-mapbox/test/components/layer.spec.jsx index 5053bf9c3..276e16825 100644 --- a/modules/react-mapbox/test/components/layer.spec.jsx +++ b/modules/react-mapbox/test/components/layer.spec.jsx @@ -83,3 +83,56 @@ test('Source/Layer', async () => { await act(() => root.unmount()); }); + +test('Source/Layer do not re-render on unrelated style changes', async () => { + const root = createRoot(document.createElement('div')); + const mapRef = {current: null}; + let renderCount = 0; + const onRender = () => renderCount++; + + const geoJSON = { + type: 'Point', + coordinates: [0, 0] + }; + const pointLayer = { + type: 'circle', + paint: { + 'circle-radius': 10, + 'circle-color': '#007cbf' + } + }; + + await act(() => + root.render( + + + + + + + + ) + ); + await waitForMapLoad(mapRef); + const map = mapRef.current.getMap(); + + renderCount = 0; + map.addImage('my-image', {width: 1, height: 1, data: new Uint8Array(4)}); + map.setPaintProperty('my-layer', 'circle-color', '#ff0000'); + await actUntil(resolveTest => map.once('idle', () => setTimeout(resolveTest, 0))); + expect(renderCount, 'Source and Layer are not rendered again').toBe(0); + + map.removeLayer('my-layer'); + await actUntil(resolveTest => { + const onStyleData = () => { + if (map.getLayer('my-layer')) { + map.off('styledata', onStyleData); + resolveTest(); + } + }; + map.on('styledata', onStyleData); + }); + expect(map.getLayer('my-layer'), 'Missing layer is added again').toBeTruthy(); + + await act(() => root.unmount()); +}); diff --git a/modules/react-maplibre/src/components/layer.ts b/modules/react-maplibre/src/components/layer.ts index 439be9c8d..830697cd7 100644 --- a/modules/react-maplibre/src/components/layer.ts +++ b/modules/react-maplibre/src/components/layer.ts @@ -92,11 +92,19 @@ export function Layer(props: LayerProps) { useEffect(() => { if (map) { const forceUpdate = () => setStyleLoaded(version => version + 1); - map.on('styledata', forceUpdate); + // `styledata` fires on every style change, e.g. addImage or setPaintProperty. + // Only re-render if the layer is missing, e.g. after the style was swapped + const onStyleData = () => { + // @ts-ignore + if (!(map.style && map.style._loaded && map.getLayer(id))) { + forceUpdate(); + } + }; + map.on('styledata', onStyleData); forceUpdate(); return () => { - map.off('styledata', forceUpdate); + map.off('styledata', onStyleData); // @ts-ignore if (map.style && map.style._loaded && map.getLayer(id)) { map.removeLayer(id); diff --git a/modules/react-maplibre/src/components/source.ts b/modules/react-maplibre/src/components/source.ts index 1a704a377..d3d4730be 100644 --- a/modules/react-maplibre/src/components/source.ts +++ b/modules/react-maplibre/src/components/source.ts @@ -93,11 +93,19 @@ export function Source(props: SourceProps) { if (map) { /* global setTimeout */ const forceUpdate = () => setTimeout(() => setStyleLoaded(version => version + 1), 0); - map.on('styledata', forceUpdate); + // `styledata` fires on every style change, e.g. addImage or setPaintProperty. + // Only re-render if the source is missing, e.g. after the style was swapped + const onStyleData = () => { + // @ts-ignore + if (!(map.style && map.style._loaded && map.getSource(id))) { + forceUpdate(); + } + }; + map.on('styledata', onStyleData); forceUpdate(); return () => { - map.off('styledata', forceUpdate); + map.off('styledata', onStyleData); // @ts-ignore if (map.style && map.style._loaded && map.getSource(id)) { // Parent effects are destroyed before child ones, see diff --git a/modules/react-maplibre/test/components/layer.spec.jsx b/modules/react-maplibre/test/components/layer.spec.jsx index eaf0d263f..6027545c1 100644 --- a/modules/react-maplibre/test/components/layer.spec.jsx +++ b/modules/react-maplibre/test/components/layer.spec.jsx @@ -1,4 +1,4 @@ -/* global document */ +/* global document, setTimeout */ import {expect, test} from 'vitest'; import * as React from 'react'; import {createRoot} from 'react-dom/client'; @@ -76,3 +76,56 @@ test('Source/Layer', async () => { await act(() => root.unmount()); }); + +test('Source/Layer do not re-render on unrelated style changes', async () => { + const root = createRoot(document.createElement('div')); + const mapRef = {current: null}; + let renderCount = 0; + const onRender = () => renderCount++; + + const geoJSON = { + type: 'Point', + coordinates: [0, 0] + }; + const pointLayer = { + type: 'circle', + paint: { + 'circle-radius': 10, + 'circle-color': '#007cbf' + } + }; + + await act(() => + root.render( + + + + + + + + ) + ); + await waitForMapLoad(mapRef); + const map = mapRef.current.getMap(); + + renderCount = 0; + map.addImage('my-image', {width: 1, height: 1, data: new Uint8Array(4)}); + map.setPaintProperty('my-layer', 'circle-color', '#ff0000'); + await actUntil(resolveTest => map.once('idle', () => setTimeout(resolveTest, 0))); + expect(renderCount, 'Source and Layer are not rendered again').toBe(0); + + map.removeLayer('my-layer'); + await actUntil(resolveTest => { + const onStyleData = () => { + if (map.getLayer('my-layer')) { + map.off('styledata', onStyleData); + resolveTest(); + } + }; + map.on('styledata', onStyleData); + }); + expect(map.getLayer('my-layer'), 'Missing layer is added again').toBeTruthy(); + + await act(() => root.unmount()); +}); From 09816b1605f731ff2f3dd00ac2e4222073520d8a Mon Sep 17 00:00:00 2001 From: Tobias Date: Wed, 14 Oct 2026 22:22:32 +0200 Subject: [PATCH 2/2] fix: re-render Source when the style has a different source with its id `` only renders its children once it has a source. When the style itself defines a source with the same id and loads after the first render, the `styledata` check found the source and skipped the render, so the children never mounted. Re-render whenever the source in the style is not the one the children were rendered with. Co-Authored-By: Claude Opus 5.5 --- modules/react-mapbox/src/components/source.ts | 8 +++- .../test/components/layer.spec.jsx | 42 +++++++++++++++++++ .../react-maplibre/src/components/source.ts | 8 +++- .../test/components/layer.spec.jsx | 37 ++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/modules/react-mapbox/src/components/source.ts b/modules/react-mapbox/src/components/source.ts index 5e0e1b184..8cf4220e3 100644 --- a/modules/react-mapbox/src/components/source.ts +++ b/modules/react-mapbox/src/components/source.ts @@ -83,6 +83,7 @@ export function Source(props: SourceProps) { const map = useContext(MapContext).map.getMap(); const propsRef = useRef(props); const [, setStyleLoaded] = useState(0); + const sourceRef = useRef(null); const id = useMemo(() => props.id || `jsx-source-${sourceCounter++}`, []); @@ -91,10 +92,12 @@ export function Source(props: SourceProps) { /* global setTimeout */ const forceUpdate = () => setTimeout(() => setStyleLoaded(version => version + 1), 0); // `styledata` fires on every style change, e.g. addImage or setPaintProperty. - // Only re-render if the source is missing, e.g. after the style was swapped + // Only re-render if the source is missing or not the one children were rendered with, + // e.g. after the style was swapped or when the style defines a source with the same id const onStyleData = () => { // @ts-ignore - if (!(map.style && map.style._loaded && map.getSource(id))) { + const source = map.style && map.style._loaded && map.getSource(id); + if (!source || source !== sourceRef.current) { forceUpdate(); } }; @@ -132,6 +135,7 @@ export function Source(props: SourceProps) { source = createSource(map, id, props); } propsRef.current = props; + sourceRef.current = source; return ( (source && diff --git a/modules/react-mapbox/test/components/layer.spec.jsx b/modules/react-mapbox/test/components/layer.spec.jsx index 276e16825..4496fc649 100644 --- a/modules/react-mapbox/test/components/layer.spec.jsx +++ b/modules/react-mapbox/test/components/layer.spec.jsx @@ -136,3 +136,45 @@ test('Source/Layer do not re-render on unrelated style changes', async () => { await act(() => root.unmount()); }); + +test('Source renders its children when the style already has its source', async () => { + const root = createRoot(document.createElement('div')); + const mapRef = {current: null}; + + const geoJSON = { + type: 'Point', + coordinates: [0, 0] + }; + const mapStyle = { + version: 8, + sources: {'my-data': {type: 'geojson', data: geoJSON}}, + layers: [] + }; + const pointLayer = { + type: 'circle', + paint: { + 'circle-radius': 10, + 'circle-color': '#007cbf' + } + }; + + await act(() => + root.render( + + + + + + ) + ); + await waitForMapLoad(mapRef); + await actUntil(resolveTest => setTimeout(resolveTest, 0)); + expect(mapRef.current.getLayer('my-layer'), 'Layer is added').toBeTruthy(); + + await act(() => root.unmount()); +}); diff --git a/modules/react-maplibre/src/components/source.ts b/modules/react-maplibre/src/components/source.ts index d3d4730be..753387469 100644 --- a/modules/react-maplibre/src/components/source.ts +++ b/modules/react-maplibre/src/components/source.ts @@ -86,6 +86,7 @@ export function Source(props: SourceProps) { const map = useContext(MapContext).map.getMap(); const propsRef = useRef(props); const [, setStyleLoaded] = useState(0); + const sourceRef = useRef(null); const id = useMemo(() => props.id || `jsx-source-${sourceCounter++}`, []); @@ -94,10 +95,12 @@ export function Source(props: SourceProps) { /* global setTimeout */ const forceUpdate = () => setTimeout(() => setStyleLoaded(version => version + 1), 0); // `styledata` fires on every style change, e.g. addImage or setPaintProperty. - // Only re-render if the source is missing, e.g. after the style was swapped + // Only re-render if the source is missing or not the one children were rendered with, + // e.g. after the style was swapped or when the style defines a source with the same id const onStyleData = () => { // @ts-ignore - if (!(map.style && map.style._loaded && map.getSource(id))) { + const source = map.style && map.style._loaded && map.getSource(id); + if (!source || source !== sourceRef.current) { forceUpdate(); } }; @@ -135,6 +138,7 @@ export function Source(props: SourceProps) { source = createSource(map, id, props); } propsRef.current = props; + sourceRef.current = source; return ( (source && diff --git a/modules/react-maplibre/test/components/layer.spec.jsx b/modules/react-maplibre/test/components/layer.spec.jsx index 6027545c1..32a897712 100644 --- a/modules/react-maplibre/test/components/layer.spec.jsx +++ b/modules/react-maplibre/test/components/layer.spec.jsx @@ -129,3 +129,40 @@ test('Source/Layer do not re-render on unrelated style changes', async () => { await act(() => root.unmount()); }); + +test('Source renders its children when the style already has its source', async () => { + const root = createRoot(document.createElement('div')); + const mapRef = {current: null}; + + const geoJSON = { + type: 'Point', + coordinates: [0, 0] + }; + const mapStyle = { + version: 8, + sources: {'my-data': {type: 'geojson', data: geoJSON}}, + layers: [] + }; + const pointLayer = { + type: 'circle', + paint: { + 'circle-radius': 10, + 'circle-color': '#007cbf' + } + }; + + await act(() => + root.render( + + + + + + ) + ); + await waitForMapLoad(mapRef); + await actUntil(resolveTest => setTimeout(resolveTest, 0)); + expect(mapRef.current.getLayer('my-layer'), 'Layer is added').toBeTruthy(); + + await act(() => root.unmount()); +});