Skip to content

[WC-3450] Complete LeafletMap MobX migration with ViewModel#2276

Open
iobuhov wants to merge 40 commits into
mainfrom
3429/complete-mobx-migration
Open

[WC-3450] Complete LeafletMap MobX migration with ViewModel#2276
iobuhov wants to merge 40 commits into
mainfrom
3429/complete-mobx-migration

Conversation

@iobuhov

@iobuhov iobuhov commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Pull request type

Refactoring (e.g. file rename, variable rename, etc.)


Description

This PR completes the MobX migration for the LeafletMap component by:

Key Changes:

  • ✨ Created LeafletMapViewModel to handle all map logic with MobX reactions
  • 🔨 Simplified LeafletMap.tsx component (removed 184 lines) to focus on rendering
  • 📦 Extracted marker creation logic into src/utils/leaflet-markers.ts
  • 🏗️ Set up proper DI with useLeafletMapVM injection hook
  • 🧪 Updated tests to work with new ViewModel architecture
  • 🎨 Fixed all linting issues (import ordering, return types)

Architecture:

  • ViewModel (LeafletMapViewModel): Manages map instance, tile layer, markers, and reactivity
  • Component (LeafletMap): Pure presentational component using ref callback for setup
  • Services: Integrated with LocationResolverService and CurrentLocationService
  • Reactive updates: MobX reaction syncs markers when locations change

Testing:

  • ✅ All 90 tests passing
  • ✅ 87% code coverage maintained
  • ✅ Linting clean (no errors or warnings)

Impact:

  • 18 files changed
  • 311 insertions(+), 284 deletions(-)
  • Better separation of concerns
  • More maintainable and testable code

This PR builds on the existing MobX infrastructure and completes the migration started in the parent branch.


What should be covered while testing?

  1. Map Rendering: Verify the map renders correctly with markers
  2. Marker Updates: Check that markers update reactively when data changes
  3. Auto Zoom: Test automatic zoom behavior with multiple markers
  4. Manual Zoom: Verify manual zoom controls work correctly
  5. Current Location: Test current location marker appears and updates
  6. Map Interactions: Drag, scroll, and zoom controls function properly
  7. Different Providers: Test with both OpenStreetMap and other providers

Test in Mendix Studio Pro:

  • Add Maps widget to a page with data source
  • Verify markers appear at correct locations
  • Test adding/removing markers dynamically
  • Check zoom and pan behavior
  • Verify no console errors or memory leaks

🤖 Generated with Claude Code

iobuhov and others added 30 commits June 10, 2026 09:26
- Replace all setTimeout/Promise delays with MobX when() helper
- Mock convertAddressToLatLng at module level for deterministic tests
- Add waitForLocations() helper using when() for observable changes
- Configure MobX with enforceActions: 'never' for testing
- Add timeout handling for error cases
- Improve test reliability and speed (3.5s vs 13s)
- All 42 tests still passing with 100% model layer coverage
Split 598-line test file into 3 self-contained files:
- LocationResolver.unit.spec.ts (247 lines) - Basic functionality, empty inputs, API keys
- LocationResolver.integration.spec.ts (321 lines) - Mixed markers, caching, errors, integration
- LocationResolver.reactivity.spec.ts (226 lines) - MobX reactions and observable behavior

Benefits:
- Each file self-contained with inline setup (no helpers folder)
- Follows Gallery/Datagrid patterns in the repo
- Easy to locate specific test types
- Can run test files independently
- No abstraction layers to learn
- Clear test intent without jumping to other files

All 45 tests passing with 100% model layer coverage maintained
Add 26 unit tests for convertStaticModeledMarker and convertDynamicModeledMarker:

convertStaticModeledMarker (5 tests):
- All fields present
- Undefined optional fields
- Number parsing with comma/period decimal separators
- Custom marker image handling

convertDynamicModeledMarker (21 tests):
- Datasource availability (undefined, Loading, Unavailable, empty)
- Coordinates location type (single/multiple markers, missing attributes)
- Address location type (with/without address attribute)
- Optional fields (title, onClick action, custom marker)
- Edge cases (item IDs, NaN handling, empty strings, mixed attributes)

Test results:
- 26/26 tests passing
- 100% code coverage on data.ts
- Self-contained tests using @mendix/widget-plugin-test-utils
- Uses list(), obj(), listAttribute(), listAction(), dynamic() helpers
Remove test 'should handle NaN from invalid coordinate strings' because:
- Dynamic markers use ListAttributeValue<Big>, not string attributes
- Mendix runtime ensures type safety - we never receive invalid coordinate types
- The scenario being tested doesn't occur in practice

Tests: 70 passed (was 71)
Coverage: Still 100% on data.ts
Remove 'should handle multiple markers with different attributes' test because:
- Already covered by 'should convert multiple markers with coordinates' test
- Doesn't add unique value - tests same scenario with different attribute values
- Simplifies test suite without losing coverage

Tests: 69 passed (was 70)
Coverage: Still 100% on data.ts
Improve markers() computed getter:
- Remove mutable array and push operations
- Store static/dynamic markers in separate variables
- Use .flat() instead of .reduce() for flattening
- Return immutable spread [...staticMarkers, ...dynamicMarkers]

Benefits:
- More functional style (no mutations)
- Clearer intent with named variables
- Modern Array.flat() is more readable than reduce
- Shorter and easier to understand

Before:
  const markers = [];
  markers.push(...static);
  const flattened = dynamic.reduce((prev, curr) => [...prev, ...curr], []);
  markers.push(...flattened);

After:
  const staticMarkers = ...
  const dynamicMarkers = ....flat();
  return [...staticMarkers, ...dynamicMarkers];

All tests passing (69/69)
Move marker equality comparison from manual check to reaction equals option:

Before:
- Manual isIdenticalMarkers() check inside effect
- Separate private method for comparison
- Early return if markers unchanged

After:
- Built-in equals option in reaction config
- MobX handles comparison before effect runs
- Cleaner effect logic without manual check

Benefits:
- More idiomatic MobX (comparison at framework level)
- Simpler effect code (no manual equality check)
- Remove isIdenticalMarkers() method (less code)
- Same behavior: still compares markers excluding action callbacks

The equals function:
- Maps prev/next to remove action field
- Uses deepEqual with strict mode
- Returns true if markers unchanged (prevents effect run)

All tests passing (69/69)
Move API key retrieval from inline expression to computed getter:

Before:
- Inline in reaction effect
- const apiKey = this.mainGate.props.geodecodeApiKeyExp?.value
  ?? this.mainGate.props.geodecodeApiKey;

After:
- Computed getter property
- get apiKey(): string | undefined
- Use this.apiKey in reaction

Benefits:
- Cleaner reaction code (one less variable)
- Better MobX reactivity tracking
- Reusable if needed elsewhere
- Clear documentation via JSDoc
- Follows computed pattern for derived values

All tests passing (69/69)
Three improvements to LocationResolverService:

1. Version counter instead of reference equality:
   - Replace requestedMarkers reference check
   - Use geocodeVersion number (++version pattern)
   - Clearer intent: 'is this the latest request?'
   - Minimal memory (number vs full array)

2. Remove runInAction wrapper:
   - updateLocations is already an action
   - No need for extra runInAction wrapper
   - Simpler, cleaner code

3. Geocode function as dependency:
   - Extract GeocodeFunction type in tokens
   - Inject via constructor (better testability)
   - Bind in RootContainer (shared utility)
   - Remove direct import of convertAddressToLatLng

Benefits:
- Better dependency injection pattern
- Easier to mock in tests
- Clearer async handling with version counter
- Less memory usage
- Follows DI best practices

All tests passing (69/69)
Move dependency injection registration from service to container:

Before:
- Service file had: injected(LocationResolverService, ...)
- Container had: injected() call in inject() method
- Duplication of DI setup

After:
- Service file: Clean, no DI registration
- Container file: Single source of DI registration
- Removed unused imports (injected, CORE_TOKENS)

Benefits:
- DI setup in one place (container)
- Service file is cleaner
- Follows separation of concerns
- Container owns binding configuration

The injected() call now lives only in Maps.container.ts
where the binding happens, not scattered in service files.

All tests passing (69/69)
Fix 'should update props when they change' test to verify correct behavior:

Before:
- Checked if config.name updated (it doesn't - config is static)
- Test was incomplete and didn't verify actual prop changes

After:
- Check mainGate.props.name (correct source of reactive props)
- Verify props actually update via the gate
- Test now validates the real behavior

Key insight:
- Config is static (bound once at container creation)
- MainGate provides reactive access to current props
- Services use mainGate.props for up-to-date values

The test now correctly verifies that:
1. Container instance remains stable (reusability)
2. MainGate provides updated props (reactivity)

All tests passing (69/69)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ests

- Create test-utils.ts with createTestContainer helper that uses real MapsContainer
- Replace jest.mock() with explicit dependency injection via container
- Override geocodeFunction binding before container initialization
- Properly trigger setup lifecycle to start MobX reactions
- All 22 LocationResolver tests passing with improved type safety and maintainability

Benefits:
- Tests now use real container setup, catching integration issues
- Explicit, type-safe mocking of dependencies
- Reusable test utilities for other service tests
- 100% coverage of LocationResolverService
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Wire MapsContainer into Maps.tsx via useMapsContainer + ContainerProvider
- Add MapsWidget observer component reading gate props and services
- Add CurrentLocationService (reactive showCurrentLocation handling,
  stale-request guard, clears location when disabled)
- Add injection-hooks following the gallery-web pattern
- Rewrite LeafletMap on the imperative Leaflet API; drop react-leaflet
  and @types/react-leaflet; add explicit mobx + mobx-react-lite
- Remove legacy useLocationResolver from utils/geodecode.ts
- Replace react-leaflet snapshots with structural LeafletMap tests (15),
  add CurrentLocationService tests (6) and Maps integration tests (2)
- Add OpenSpec change complete-mobx-migration (tdd-refactor schema)

Tests: 77 passed across 9 suites; tsc and eslint clean; Maps.mpk builds

Co-Authored-By: Claude <noreply@anthropic.com>
…ct marker utils, fix linting

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
iobuhov and others added 8 commits June 18, 2026 13:21
…ived in maps-web)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…emove baseMapLayer utility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
… cleanup

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…/tile-layer.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…fect

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…tile-layer utility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@iobuhov iobuhov requested a review from a team as a code owner June 18, 2026 14:25
@iobuhov iobuhov changed the title [WC-3429] Complete LeafletMap MobX migration with ViewModel [WC-3450] Complete LeafletMap MobX migration with ViewModel Jun 18, 2026

- **WHEN** `apiKeyExp.value` is undefined or empty
- **AND** `apiKey` is a non-empty string
- **THEN** the atom returns the static `apiKey` value

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When expression is still loading, value is going to be undefined for a bit bfore taking final shape, it shouldn't fall back to apiKey when apiKeyExp is not undefined. The sign that expression is not configured is that whole apiKeyExp is undefined, not just value.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point, but I don't think it's possible to configure both. You ether have API key exp, or API key.

Base automatically changed from 3429/migrate-maps-to-mobx to main June 24, 2026 14:51
iobuhov and others added 2 commits June 24, 2026 17:18
Resolved conflicts by preserving the branch's ViewModel implementation:
- Maps.config.ts: kept showCurrentLocation config
- Maps.container.ts: kept CurrentLocationService and LeafletMapViewModel bindings
- Root.container.ts: kept getLocationFunction binding
- LocationResolver.service.ts: kept geodecodeApiKeyAtom dependency
- tokens.ts: kept apiKey, geodecodeApiKey, getLocationFunction, and leafletMapVM tokens
- test files: kept complete test utilities including getCurrentLocationService
- mock-container-props.ts: kept branch version without 'advanced' property

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…c apiKey

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

AI Code Review

🔶 Changes requested — one or more medium-severity items must be addressed


What was reviewed

File Change
src/Maps.tsx Rewired to use useMapsContainer + ContainerProvider (gallery pattern)
src/Maps.xml Removed advanced property
src/Maps.editorConfig.ts Removed platform branching, added deprecation warnings
src/components/MapsWidget.tsx New observer component bridging MobX model and map views
src/components/LeafletMap.tsx Stripped react-leaflet, now delegates to LeafletMapViewModel via ref callback
src/components/GoogleMap.tsx Import reorder only
src/model/atoms/apiKey.atom.ts New reactive API key atom with caching
src/model/atoms/geodecodeApiKey.atom.ts Same pattern for geodecode key
src/model/atoms/__tests__/apiKey.atom.spec.ts Unit tests for atom
src/model/atoms/__tests__/geodecodeApiKey.atom.spec.ts Unit tests for geodecode atom
src/model/containers/Maps.container.ts Binds atoms, wires LeafletMapViewModel
src/model/containers/Root.container.ts Binds geocode and location functions
src/model/containers/createMapsContainer.ts Factory helper
src/model/hooks/injection-hooks.ts DI hooks for all tokens
src/model/hooks/useMapsContainer.ts React hook wrapping container lifecycle
src/model/services/CurrentLocation.service.ts New service replacing inline useEffect/useState
src/model/services/LocationResolver.service.ts Updated to use injected geodecode atom
src/model/services/__tests__/CurrentLocation.spec.ts 4 tests covering service lifecycle
src/model/services/__tests__/LocationResolver.*.spec.ts Existing test suites
src/model/tokens.ts Added apiKey, geodecodeApiKey, leafletMapVM tokens
src/model/viewmodels/LeafletMap.viewModel.ts New ViewModel managing Leaflet imperatively
src/utils/geodecode.ts Removed legacy hook, kept convertAddressToLatLng
src/utils/leaflet-markers.ts Extracted marker creation logic
src/utils/mock-container-props.ts Removed advanced prop
src/__tests__/Maps.spec.tsx 2 integration tests
src/components/__tests__/LeafletMap.spec.tsx Rewrote 9 snapshot tests → 13 structural tests
src/components/__tests__/MapsWidget.spec.tsx 6 render-gating tests
src/components/__tests__/GoogleMap.spec.tsx Wrapped renders in act()
CHANGELOG.md Unreleased entry added
package.json Replaced react-leaflet with explicit mobx/mobx-react-lite
typings/MapsProps.d.ts Regenerated (removed advanced)
openspec/ Archived completed changes; new in-progress change

Skipped (out of scope): pnpm-lock.yaml, openspec/ docs only


Findings

🔶 Medium — useEffect deps array missing in useMapsContainer

File: src/model/hooks/useMapsContainer.ts line 15
Problem: useEffect(() => mainProvider.setProps(props)) has no dependency array, so it runs after every render — not only when props change. This is likely intentional (MobX gate update), but without the dep array it re-runs on every render caused by unrelated state changes in parent trees, which could cause unnecessary work.
Fix: Add props as the dependency (or document the intentional omission with a comment):

useEffect(() => mainProvider.setProps(props), [mainProvider, props]);

If the intent is to always sync (because the gate diffs internally), add a short comment explaining that.


🔶 Medium — CurrentLocationService does not react to showCurrentLocation changing at runtime

File: src/model/services/CurrentLocation.service.ts lines 26–43
Problem: setup() checks showCurrentLocation once at mount time, then fires getLocation() and returns a disposer that only sets disposed = true. If the user toggles showCurrentLocation from falsetrue after mount, the location is never resolved. The PR description and the archived design doc say "Resolves location when option becomes true" and "Clears location when option becomes false" are design goals, but the current implementation does not use a MobX reaction on the prop — it reads a snapshot at setup() time.
Fix: Wrap in a reaction on this.config.showCurrentLocation (or read directly from mainGate.props):

setup(): () => void {
    return reaction(
        () => this.config.showCurrentLocation,  // or mainGate.props.showCurrentLocation
        (show) => {
            if (!show) { this.updateLocation(undefined); return; }
            let stale = false;
            this.getLocation()
                .then(loc => { if (!stale) this.updateLocation(loc); })
                .catch(e => console.error(e));
            return () => { stale = true; };  // reaction cleanup
        },
        { fireImmediately: true }
    );
}

Note: The design doc says "Clears location when option becomes false" and "Ignores stale responses" — neither is implemented.


🔶 Medium — Missing tests for CurrentLocationService reactive behavior

File: src/model/services/__tests__/CurrentLocation.spec.ts
Problem: The existing test suite has 4 tests but the archived design doc specifies 6 (including "Resolves location when option becomes true" and "Clears location when option becomes false"). These integration scenarios are missing. Given the service currently doesn't support runtime toggling (see finding above), these tests would also catch the regression.
Fix: Add the two missing scenarios from the design doc before merging.


⚠️ Low — Inline style template literal in MapsWidget loading state

File: src/components/MapsWidget.tsx line 19
Note: The early-return div uses a template literal for className:

<div className={`widget-maps ${props.class}`} ...>

All other render paths use classNames("widget-maps", props.class). For consistency (and to avoid a trailing space when props.class is empty), use classNames here too:

import classNames from "classnames";
<div className={classNames("widget-maps", props.class)} style={...} />

⚠️ Low — LeafletMap.viewModel.ts tile layer not updated on provider/token change at runtime

File: src/model/viewmodels/LeafletMap.viewModel.ts lines 52–55
Note: The tile layer is created once in setupMap() and never updated if mapProvider or apiKey change at runtime. The reaction only observes locations and currentLocation. If a user changes the map provider in Studio Pro at runtime, the tile layer will not update. This may be out of scope for this PR (it's an existing limitation) but worth flagging for a follow-up.


Positives

  • The MobX container architecture is well-structured with clear define/init/postInit phases, matching the gallery pattern exactly.
  • Removing react-leaflet and replacing it with the imperative Leaflet API eliminates the known default-icon hack and simplifies the dep graph — well-motivated change.
  • The apiKeyAtom caching via a closure variable inside computed is a clean, idiomatic approach; the design doc explains the reasoning clearly (MobX drops deps on non-reads once cached !== null).
  • Snapshot tests for LeafletMap were replaced with structural/behavioral assertions — this is the right call: snapshot tests on react-leaflet wrapper DOM were fragile and unreadable.
  • The LeafletMapViewModel properly disposes the reaction and calls map.remove() in disposeMap(), preventing memory leaks.
  • All OpenSpec change artifacts are archived cleanly, and the in-progress maps-defer-render-until-key change is properly staged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants