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
5 changes: 5 additions & 0 deletions .changeset/fix-basemap-globe-style-not-loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@accelint/map-toolkit': patch
---

Fix `BaseMap` runtime error "Style is not done loading." when `defaultView='3D'` (globe projection). Projection is now applied via `setProjection` after MapLibre's style has loaded, instead of being passed as the initial `projection` prop to `react-map-gl/maplibre`.
103 changes: 91 additions & 12 deletions packages/map-toolkit/src/deckgl/base-map/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,110 @@
*/

import { uuid } from '@accelint/core';
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { BaseMap, stripLockedMapLibreOptions } from './index';
import { LOCKED_MAP_LIBRE_OPTION_KEYS } from './types';
import type { MapOptions } from 'maplibre-gl';
import type { MapLibreOptions } from './types';

// Mock MapLibre hook since it requires browser APIs
vi.mock('../../maplibre/hooks/use-maplibre', () => ({
useMapLibre: vi.fn(),
interface FakeMap {
setProjection: Mock;
isStyleLoaded: Mock;
}

function createFakeMap(): FakeMap {
return {
setProjection: vi.fn(),
isStyleLoaded: vi.fn().mockReturnValue(false),
};
}

let activeMap: FakeMap | null = null;
let capturedOnLoad: (() => void) | undefined;

function useFakeMap(map: FakeMap): void {
activeMap = map;
}

function fireMapLoad(): void {
capturedOnLoad?.();
}

// Mock react-map-gl/maplibre so tests can drive the MapLibre lifecycle
// (ref population + onLoad event) without a real WebGL context.
vi.mock('react-map-gl/maplibre', () => ({
Map: ({
children,
onLoad,
ref,
}: {
children?: React.ReactNode;
onLoad?: () => void;
ref?: { current: unknown };
}) => {
if (ref && typeof ref === 'object') {
ref.current = { getMap: () => activeMap };
}
capturedOnLoad = onLoad;

return <div data-testid='maplibre-mock'>{children}</div>;
},
useControl: vi.fn(),
}));

beforeEach(() => {
activeMap = null;
capturedOnLoad = undefined;
});

describe('BaseMap', () => {
it('should render without crashing when given a valid id', () => {
const { container } = render(<BaseMap id={uuid()} />);
useFakeMap(createFakeMap());

render(<BaseMap id={uuid()} />);

expect(screen.getByTestId('maplibre-mock')).toBeInTheDocument();
});

it('should apply className to the wrapping element', () => {
useFakeMap(createFakeMap());

render(<BaseMap className='custom-map-class' id={uuid()} />);

const map = screen.getByTestId('maplibre-mock');

expect(map.closest('.custom-map-class')).not.toBeNull();
});

it('should apply globe projection after MapLibre style loads when defaultView is 3D', () => {
const fakeMap = createFakeMap();
useFakeMap(fakeMap);

render(<BaseMap id={uuid()} defaultView='3D' />);

expect(container.firstChild).toBeInTheDocument();
expect(fakeMap.setProjection).not.toHaveBeenCalled();

act(() => {
fakeMap.isStyleLoaded.mockReturnValue(true);
fireMapLoad();
});

expect(fakeMap.setProjection).toHaveBeenCalledWith({ type: 'globe' });
});

it('should apply className to the root element', () => {
const { container } = render(
<BaseMap className='custom-map-class' id={uuid()} />,
);
it('should apply mercator projection after MapLibre style loads when defaultView is 2D', () => {
const fakeMap = createFakeMap();
useFakeMap(fakeMap);

render(<BaseMap id={uuid()} defaultView='2D' />);

act(() => {
fakeMap.isStyleLoaded.mockReturnValue(true);
fireMapLoad();
});

expect(container.firstChild).toHaveClass('custom-map-class');
expect(fakeMap.setProjection).toHaveBeenCalledWith({ type: 'mercator' });
});
});

Expand Down
37 changes: 27 additions & 10 deletions packages/map-toolkit/src/deckgl/base-map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useEffectEvent, useEmit } from '@accelint/bus/react';
import { Deckgl, useDeckgl } from '@deckgl-fiber-renderer/dom';
import type { PickingInfo, ViewStateChangeParameters } from '@deck.gl/core';
import 'client-only';
import { useCallback, useId, useMemo, useRef } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef } from 'react';
import {
Map as MapLibre,
type MapRef,
Expand Down Expand Up @@ -310,6 +310,13 @@ export function BaseMap({
);

// Spread order: default → consumer overrides → locked keys (last wins).
//
// Don't add `projection` here. react-map-gl's `_updateSettings` iterates
// its `settingNames` list (which includes `projection`) on every `setProps`
// call and invokes each setter unconditionally - bypassing the style-load
// guard on its `_updateStyleComponents` path. maplibre's `setProjection`
// then throws "Style is not done loading." Sync via the post-load pattern
// below - same approach as `useMapLibre` uses for the same setter.
const mapOptions = useMemo(
() => ({
attributionControl: DEFAULT_ATTRIBUTION_CONTROL,
Expand All @@ -324,21 +331,25 @@ export function BaseMap({
dragRotate: false,
pitchWithRotate: false,
rollEnabled: false,
projection: cameraState.projection,
maxPitch: cameraState.view === '2D' ? 0 : 85,
canvasContextAttributes: CANVAS_CONTEXT_ATTRIBUTES,
boxZoom,
}),
[
viewState,
container,
cameraState.projection,
cameraState.view,
boxZoom,
filteredMapLibreOptions,
],
[viewState, container, cameraState.view, boxZoom, filteredMapLibreOptions],
);

// `setProjection` throws if called before the style is loaded. Initial
// application happens in `handleMapLoad`; this effect syncs later changes.
useEffect(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am still slightly curious as to why this isn't an issue in the baseline react-map-gl package 🤔 still leading me to believe we are chasing a potential red herring or something.

const map = mapRef.current?.getMap();

if (!map?.isStyleLoaded()) {
return;
}

map.setProjection({ type: cameraState.projection });
}, [cameraState.projection]);

const emitClick = useEmit<MapClickEvent>(MapEvents.click);
const emitHover = useEmit<MapHoverEvent>(MapEvents.hover);
const emitViewport = useEmit<MapViewportEvent>(MapEvents.viewport);
Expand Down Expand Up @@ -444,6 +455,11 @@ export function BaseMap({
}, 200);
});

// First point at which `setProjection` is safe (after `style.load`).
const handleMapLoad = useEffectEvent(() => {
mapRef.current?.getMap().setProjection({ type: cameraState.projection });
});

const handleLoad = useEffectEvent(() => {
//--- force update viewport state once all viewports initialized ---
// @ts-expect-error squirrelly deckglInstance typing
Expand Down Expand Up @@ -498,6 +514,7 @@ export function BaseMap({
onMove={(evt) => {
setCameraState(evt.viewState);
}}
onLoad={handleMapLoad}
mapStyle={styleUrl}
ref={mapRef}
{...mapOptions}
Expand Down
Loading