Skip to content

Commit

Permalink
feat(map): implement initial version of map-instance caching (#349)
Browse files Browse the repository at this point in the history
Introduces map-instance caching, allowing maps to be persisted across unmount/remount cycles.

Fixes #319 

BREAKING CHANGE: Introduction of map instance caching needed a change to the DOM-Structure produced by the map component (added a div-element owned by the Map component to contain the map instance).
  • Loading branch information
usefulthink committed May 8, 2024
1 parent 56f1659 commit 4a6e83a
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 13 deletions.
66 changes: 62 additions & 4 deletions docs/api-reference/components/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@ const App = () => (
);
```

By default, the map will be added to the DOM with `width: 100%; height: 100%;`,
assuming that the parent element will establish a size for the map. If that
doesn't work in your case, you can adjust the styling of the map container
using the [`style`](#style-reactcssproperties) and [`className`](#classname-string) props.
:::note

By default, the intrinsic height of a Google map is 0.
To prevent this from causing confusion, the Map component uses a
default-style of `width: 100%; height: 100%;` for the div-element containing
the map, assuming that the parent element will establish a size for the map.
If that doesn't work in your case, you can adjust the styling of the map
container using the [`style`](#style-reactcssproperties) and
[`className`](#classname-string) props.

:::

## Controlled and Uncontrolled Props

Expand Down Expand Up @@ -70,6 +77,31 @@ enabled by the [`controlled` prop](#controlled-boolean). When this mode is
active, the map will disable all control inputs and will reject to render
anything not specified in the camera props.

## Map Instance Caching

If your application renders the map on a subpage or otherwise mounts and
unmounts the `Map` component a lot, this can cause new map instances to be
created with every mount of the component. Since the pricing of the Google
Maps JavaScript API is based on map-views (effectively calls to the
`google.maps.Map` constructor), this can quickly become a problem.

The `Map` component can be configured to re-use already created maps with
the `reuseMaps` prop. When enabled, all `Map` components created with the same
`mapId` will reuse previously created instances instead of creating new ones.

:::warning

In the 1.0.0 version, support for map-caching is still a bit experimental, and
there are some known issues when maps are being reused with different sets
of options applied. In most simpler situations, like when showing the very
same component multiple times, it should work fine.

If you experience any problems using this feature, please file a [bug-report]
[rgm-new-issue]
or [start a discussion][rgm-discussions] on GitHub.

:::

## Props

The `MapProps` type extends the [`google.maps.MapOptions` interface][gmp-map-options]
Expand Down Expand Up @@ -106,6 +138,22 @@ A string that identifies the map component. This is required when multiple
maps are present in the same APIProvider context to be able to access them using the
[`useMap`](../hooks/use-map.md) hook.

#### `mapId`: string

The [Map ID][gmp-mapid] of the map.

:::info

The Maps JavaScript API doesn't allow changing the Map ID of a map after it
has been created. This isn't the case for this component. However, the
internal `google.maps.Map` instance has to be recreated when the `mapId` prop
changes, which might cause additional cost.

See the [reuseMaps](#reusemaps-boolean) parameter if your application has to
repeatedly switch between multiple Map IDs.

:::

#### `style`: React.CSSProperties

Additional style rules to apply to the map dom-element. By default, this will
Expand All @@ -117,6 +165,13 @@ Additional css class-name to apply to the element containing the map.
When a classname is specified, the default width and height of the map from the
style-prop is no longer applied.

#### `reuseMaps`: boolean

Enable map-instance caching for this component. When caching is enabled,
this component will reuse map instances created with the same `mapId`.

See also the section [Map Instance Caching](#map-instance-caching) above.

### Camera Control

#### `center`: [google.maps.LatLngLiteral][gmp-ll]
Expand Down Expand Up @@ -272,6 +327,9 @@ to get access to the `google.maps.Map` object rendered in the `<Map>` component.
[gmp-llb]: https://developers.google.com/maps/documentation/javascript/reference/coordinates#LatLngBoundsLiteral
[gmp-ll]: https://developers.google.com/maps/documentation/javascript/reference/coordinates#LatLngLiteral
[gmp-coordinates]: https://developers.google.com/maps/documentation/javascript/coordinates
[gmp-mapid]: https://developers.google.com/maps/documentation/get-map-id
[api-provider]: ./api-provider.md
[get-max-tilt]: https://github.com/visgl/react-google-maps/blob/4319bd3b68c40b9aa9b0ce7f377b52d20e824849/src/libraries/limit-tilt-range.ts#L4-L19
[map-source]: https://github.com/visgl/react-google-maps/tree/main/src/components/map
[rgm-new-issue]: https://github.com/visgl/react-google-maps/issues/new/choose
[rgm-discussions]: https://github.com/visgl/react-google-maps/discussions
2 changes: 1 addition & 1 deletion src/components/__tests__/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe('creating and updating map instance', () => {
expect(createMapSpy).toHaveBeenCalled();

const [actualEl, actualOptions] = createMapSpy.mock.lastCall!;
expect(actualEl).toBe(screen.getByTestId('map'));
expect(screen.getByTestId('map')).toContainElement(actualEl);
expect(actualOptions).toMatchObject({
center: {lat: 53.55, lng: 10.05},
zoom: 12,
Expand Down
5 changes: 5 additions & 0 deletions src/components/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ export type MapProps = google.maps.MapOptions &
*/
controlled?: boolean;

/**
* Enable caching of map-instances created by this component.
*/
reuseMaps?: boolean;

defaultCenter?: google.maps.LatLngLiteral;
defaultZoom?: number;
defaultHeading?: number;
Expand Down
77 changes: 69 additions & 8 deletions src/components/map/use-map-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,38 @@ import {
useTrackedCameraStateRef
} from './use-tracked-camera-state-ref';

/**
* Stores a stack of map-instances for each mapId. Whenever an
* instance is used, it is removed from the stack while in use,
* and returned to the stack when the component unmounts.
* This allows us to correctly implement caching for multiple
* maps om the same page, while reusing as much as possible.
*
* FIXME: while it should in theory be possible to reuse maps solely
* based on the mapId (as all other parameters can be changed at
* runtime), we don't yet have good enough tracking of options to
* reliably unset all the options that have been set.
*/
class CachedMapStack {
static entries: {[key: string]: google.maps.Map[]} = {};

static has(key: string) {
return this.entries[key] && this.entries[key].length > 0;
}

static pop(key: string) {
if (!this.entries[key]) return null;

return this.entries[key].pop() || null;
}

static push(key: string, value: google.maps.Map) {
if (!this.entries[key]) this.entries[key] = [];

this.entries[key].push(value);
}
}

/**
* The main hook takes care of creating map-instances and registering them in
* the api-provider context.
Expand Down Expand Up @@ -39,6 +71,7 @@ export function useMapInstance(
defaultZoom,
defaultHeading,
defaultTilt,
reuseMaps,

...mapOptions
} = props;
Expand Down Expand Up @@ -80,27 +113,47 @@ export function useMapInstance(
if (!container || !apiIsLoaded) return;

const {addMapInstance, removeMapInstance} = context;

const mapId = props.mapId;
const newMap = new google.maps.Map(container, mapOptions);
const cacheKey = mapId || 'default';
let mapDiv: HTMLElement;
let map: google.maps.Map;

if (reuseMaps && CachedMapStack.has(cacheKey)) {
map = CachedMapStack.pop(cacheKey) as google.maps.Map;
mapDiv = map.getDiv();

container.appendChild(mapDiv);
map.setOptions(mapOptions);

// detaching the element from the DOM lets the map fall back to its default
// size, setting the center will trigger reloading the map.
setTimeout(() => map.setCenter(map.getCenter()!), 0);
} else {
mapDiv = document.createElement('div');
mapDiv.style.height = '100%';
container.appendChild(mapDiv);
map = new google.maps.Map(mapDiv, mapOptions);
}

setMap(newMap);
addMapInstance(newMap, id);
setMap(map);
addMapInstance(map, id);

if (defaultBounds) {
newMap.fitBounds(defaultBounds);
map.fitBounds(defaultBounds);
}

// prevent map not rendering due to missing configuration
else if (!hasZoom || !hasCenter) {
newMap.fitBounds({east: 180, west: -180, south: -90, north: 90});
map.fitBounds({east: 180, west: -180, south: -90, north: 90});
}

// the savedMapState is used to restore the camera parameters when the mapId is changed
if (savedMapStateRef.current) {
const {mapId: savedMapId, cameraState: savedCameraState} =
savedMapStateRef.current;
if (savedMapId !== mapId) {
newMap.setOptions(savedCameraState);
map.setOptions(savedCameraState);
}
}

Expand All @@ -111,8 +164,16 @@ export function useMapInstance(
cameraState: cameraStateRef.current
};

// remove all event-listeners to minimize memory-leaks
google.maps.event.clearInstanceListeners(newMap);
// detach the map-div from the dom
mapDiv.remove();

if (reuseMaps) {
// push back on the stack
CachedMapStack.push(cacheKey, map);
} else {
// remove all event-listeners to minimize the possibility of memory-leaks
google.maps.event.clearInstanceListeners(map);
}

setMap(null);
removeMapInstance(id);
Expand Down

0 comments on commit 4a6e83a

Please sign in to comment.