Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 32 additions & 20 deletions src/components/advanced-marker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,25 @@ export type CustomMarkerContent = HTMLDivElement | null;

export type AdvancedMarkerRef = google.maps.marker.AdvancedMarkerElement | null;

function useAdvancedMarker(props: AdvancedMarkerProps) {
function useAdvancedMarker({
children,
onClick,
className,
onMouseEnter,
onMouseLeave,
onDrag,
onDragStart,
onDragEnd,
collisionBehavior,
clickable,
draggable,
position: positionProp,
title,
zIndex,
anchorPoint,
anchorLeft,
anchorTop
}: AdvancedMarkerProps) {
const [marker, setMarker] =
useState<google.maps.marker.AdvancedMarkerElement | null>(null);
const [contentContainer, setContentContainer] =
Expand All @@ -209,25 +227,19 @@ function useAdvancedMarker(props: AdvancedMarkerProps) {
const map = useMap();
const markerLibrary = useMapsLibrary('marker');

const {
children,
onClick,
className,
onMouseEnter,
onMouseLeave,
onDrag,
onDragStart,
onDragEnd,
collisionBehavior,
clickable,
draggable,
position,
title,
zIndex,
anchorPoint,
anchorLeft,
anchorTop
} = props;
const positionLng = positionProp?.lng;
const positionLat = positionProp?.lat;
const position = useMemo(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason why I added this was so that devs can still send inline positions to AdvancedMarker, e.g:

return <AdvancedMarker position={{ lat:1, lng:1}}>

And we make sure to not set the marker to this position again (which can trigger potentially heavy calculations on google maps API side. At least to my knowledge they don't do a deep comparison to check if the previously assigned position is deeply different from the new one.

(): google.maps.marker.AdvancedMarkerElement['position'] =>
positionLng !== undefined && positionLat !== undefined
? {
lat:
typeof positionLat === 'function' ? positionLat() : positionLat,
lng: typeof positionLng === 'function' ? positionLng() : positionLng
}
: undefined,
[positionLat, positionLng]
);

const numChildren = Children.count(children);

Expand Down
23 changes: 16 additions & 7 deletions src/hooks/use-dom-event-listener.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {useEffect} from 'react';
import {useEffectEvent} from './useEffectEvent';

const noop = () => {};

/**
* Internally used to bind events to DOM nodes.
* @internal
*/
export function useDomEventListener<T extends (...args: any[]) => void>(
Copy link
Contributor Author

@rodrigofariow rodrigofariow Nov 5, 2025

Choose a reason for hiding this comment

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

For both useDomEventListener and useMapsEventListener, to prevent adding and removing listeners on every render (which was the previous logic), I verify if the callback is defined and I useEffectEvent as the callback. eslint makes sure callbackEvent is not set on useEffect dependencies.
The tricky part of useEffectEvent, both the ponyfill and the "native" implementation, is that the returned function is a new one every time. As such, we should not directly assign callbackEvent to the addEventListener or addListener functions as we risk sending a different function on removeEventListener than the one on addEventListener, which could theoretically cause a memory leak.
I believe this is per design as you can see on https://github.com/facebook/react/blob/b7e2de632b2a160bc09edda1fbb9b8f85a6914e8/packages/react-reconciler/src/ReactFiberHooks.js#L2728

target?: Node | null,
name?: string,
callback?: T | null
target: Node | null | undefined,
name: string,
callback: T | null | undefined
) {
const callbackEvent = useEffectEvent(callback ?? noop);
const isCallbackDefined = !!callback;
useEffect(() => {
if (!target || !name || !callback) return;
if (!target || !isCallbackDefined) return;

// According to react 19 useEffectEvent and our ponyfill, the callback returned by useEffectEvent is NOT stable
// Thus, we need to create a stable listener callback that we can then use to removeEventListener with.
const listenerCallback: EventListener = (...args) => callbackEvent(...args);

target.addEventListener(name, callback);
target.addEventListener(name, listenerCallback);

return () => target.removeEventListener(name, callback);
}, [target, name, callback]);
return () => target.removeEventListener(name, listenerCallback);
}, [target, name, isCallbackDefined]);
}
24 changes: 18 additions & 6 deletions src/hooks/use-maps-event-listener.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {useEffect} from 'react';
import {useEffectEvent} from './useEffectEvent';

const noop = () => {};

/**
* Internally used to bind events to Maps JavaScript API objects.
* @internal
*/
export function useMapsEventListener<T extends (...args: any[]) => void>(
target?: object | null,
name?: string,
callback?: T | null
target: object | null,
name: string,
callback: T | null | undefined
) {
const callbackEvent = useEffectEvent(callback ?? noop);
const isCallbackDefined = !!callback;
useEffect(() => {
if (!target || !name || !callback) return;
if (!target || !isCallbackDefined) return;

const listener = google.maps.event.addListener(target, name, callback);
// According to react 19 useEffectEvent and our ponyfill, the callback returned by useEffectEvent is NOT stable
// Thus, it's best to create a stable listener callback to add to the event listener.
const listenerCallback = (...args: any[]) => callbackEvent(...args);
const listener = google.maps.event.addListener(
target,
name,
listenerCallback
);

return () => listener.remove();
}, [target, name, callback]);
}, [target, name, isCallbackDefined]);
}
37 changes: 37 additions & 0 deletions src/hooks/useEffectEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* This file is based on https://github.com/sanity-io/use-effect-event/blob/main/src/useEffectEvent.ts
*/

import {useRef, useInsertionEffect} from 'react';

function forbiddenInRender() {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering."
);
}

/**
* This is a ponyfill of the upcoming `useEffectEvent` hook that'll arrive in React 19.
* https://19.react.dev/learn/separating-events-from-effects#declaring-an-effect-event
* To learn more about the ponyfill itself, see: https://blog.bitsrc.io/a-look-inside-the-useevent-polyfill-from-the-new-react-docs-d1c4739e8072
* @public
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useEffectEvent<const T extends (...args: any[]) => void>(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is based on https://github.com/sanity-io/use-effect-event/blob/main/src/useEffectEvent.ts and on a ponyfill that I use at my company.
But for transparency, I essentially copied the one from https://github.com/sanity-io/use-effect-event/blob/main/src/useEffectEvent.ts, which is currently widely used, and removed their extra check that uses some weird React.use thing to make sure devs don't use this useEffectEvent in render. Reasons I didn't add that part:

  • It's simpler this way
  • Eslint react plugin takes care of identifying if we only use it for useEffect events or if we use it somewhere else.

fn: T
): T {
/**
* For both React 18 and 19 we set the ref to the forbiddenInRender function, to catch illegal calls to the function during render.
* Once the insertion effect runs, we set the ref to the actual function.
*/
const ref = useRef(forbiddenInRender as T);

useInsertionEffect(() => {
ref.current = fn;
}, [fn]);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return ((...args: any[]) => {
return ref.current(...args);
}) as T;
}