Skip to content

MapView silently ignores overlays wrapped in a Fragment or a custom component #134

Description

@jkasprzyk17

Problem

<MapView> never renders its children. useCollectedOverlays walks them once with
Children.forEach, keeps the elements whose type is one of the five overlay components, and
hands the resulting descriptor arrays to the native view (MapView.tsx:98, :294-297);
NativeMapView itself is rendered self-closing (MapView.tsx:272-310). Anything the walk
does not recognise is dropped, and nothing reports that it happened.

Children.forEach has two documented limits
(react.dev — Children, caveats):
it flattens arrays, but it does not traverse Fragments, and it does not render elements,
so it never sees what a component would return. In the collector:

  • <>…</> / <Fragment key=…> arrives as one element whose type is
    Symbol(react.fragment). isOverlayChild (useCollectedOverlays.ts:49-62) tests
    typeof child.type for 'function' / 'object' and then reference equality — a symbol
    passes neither, so the Fragment is skipped together with everything inside it.
  • a wrapper such as <StoreMarkers stores={…} /> arrives as an element whose type is the
    wrapper function. It has no static overlayType and is not one of the overlay components,
    so it is skipped too. Its render function is never called.

The same goes for any other element (<View>, a <Callout> left over from
react-native-maps): ignored, no __DEV__ warning.

<MapView> children Collected Warning
{items.map((i) => <Marker key={i.id} … />)} ✅ all –
[[<Marker/>], [<Marker/>, [<Marker/>]]] (nested arrays) ✅ all –
<>{items.map(…)}</> ❌ none none
<Marker/> + <Fragment key="group"><Marker/><Polyline/></Fragment> ⚠️ only the direct <Marker/> none
<StoreMarkers stores={…} /> ❌ none none
<View/> / any unknown element ignored none

Impact

The failure mode is an empty — or partially empty — map with no error, no warning and no
crash, in development and production alike. The shapes that trigger it are ordinary React:

  • grouping overlays in a component (<StoreMarkers/>, <RouteOverlay/>) — the most common
    way to split a map screen up
  • a render helper that returns <>…</> — renderRoute() returning a <Polyline> plus its
    two end <Marker>s
  • a conditional group: {showRoute && <><Polyline … /><Marker … /></>}

It hits migrations from react-native-maps hardest. There, <MapView> children are rendered
like any other React subtree, so wrapper components and Fragments both work; the same JSX
moved to react-native-better-maps shows a map with nothing on it. Neither the README's
migration table (README.md:362-372), the quick start (README.md:199-231) nor
MapViewProps.children (map.ts:46, a plain ReactNode with no JSDoc) says that overlays
have to be direct children.

The example app never exercises this path — every scenario uses the bulk props, and the
only child it ever passes is a single <Geojson> (example/examples/overlaySource.tsx:31-48)
— which is why it went unnoticed.

Reproduction

import { MapView, Marker } from 'react-native-better-maps';

const stores = [
  { id: 'a', coordinate: { latitude: 52.2297, longitude: 21.0122 } },
  { id: 'b', coordinate: { latitude: 52.237, longitude: 21.017 } },
];
const region = { latitude: 52.233, longitude: 21.015, latitudeDelta: 0.05, longitudeDelta: 0.05 };

// 1. Fragment — empty map
<MapView style={{ flex: 1 }} region={region}>
  <>
    {stores.map((s) => <Marker key={s.id} id={s.id} coordinate={s.coordinate} />)}
  </>
</MapView>

// 2. Wrapper component — empty map; StoreMarkers is never even called
function StoreMarkers() {
  return stores.map((s) => <Marker key={s.id} id={s.id} coordinate={s.coordinate} />);
}
<MapView style={{ flex: 1 }} region={region}>
  <StoreMarkers />
</MapView>

// 3. Works — the same markers as direct children
<MapView style={{ flex: 1 }} region={region}>
  {stores.map((s) => <Marker key={s.id} id={s.id} coordinate={s.coordinate} />)}
</MapView>

Observed on 2026-09-17 against v1.2.1 (ccbc2a8) in a fresh Expo SDK 57 consumer app:
the Fragment shape on iOS (Apple provider) and the wrapper shape on Android (Google
provider) both render an empty map. The collector is platform-independent, so each shape
fails on both platforms.

Hook-level confirmation, useCollectedOverlays under bun test with React 19.2.3 (hook
invoked with a minimal useRef / useMemo dispatcher — see Regression tests for why a
real test needs an extraction first):

✓ direct array children (items.map)                          → 2 markers
✓ nested arrays                                              → 3 markers, in order
✗ <>{items.map(…)}</>                                        → 0 markers, console.warn never called
✗ [<Marker/>, <Fragment key="g"><Marker/><Polyline/></>]     → 1 marker, 0 polylines, no warning
✗ <MyMarkers />                                              → 0 markers, MyMarkers never invoked, no warning
✗ [<Marker/>, <NotAnOverlay/>, <div/>]                       → 1 marker, no warning

Where

  • package/src/hooks/useCollectedOverlays.ts:209-224 — the Children.forEach walk;
    an element no collector matches falls out of the for loop with no else
  • package/src/hooks/useCollectedOverlays.ts:49-62 — isOverlayChild: static
    overlayType or reference equality; a symbol type (Fragment) can never match
  • package/src/components/MapView.tsx:98, :272-310 — the only consumer of children;
    NativeMapView is rendered without any
  • package/src/overlays/overlayCollect.ts:32-42 — resolveOverlayId, the marker-N
    fallback ids that a Fragment unwrap has to keep stable
  • package/src/types/map.ts:46 — children?: ReactNode, no JSDoc
  • README.md:199-231, README.md:362-372, docs/architecture.md:105,
    package/src/native/README.md:35 — describe the collection, not its limits

Suggested fix

Two halves. Fragments can be supported; wrapper components cannot — the collector runs
inside MapView's render and has no renderer to hand — so they need a warning and a
documented rule.

1. Unwrap Fragments recursively

When child.type === Fragment, recurse into child.props.children instead of testing the
Fragment against the collectors. Depth-first, in document order, so the fallback ids
(marker-0, marker-1, …) stay stable and match what the same markers get as direct
children. Keys play no part in collection — ids come from props, not from key.

function walk(node: ReactNode): void {
  Children.forEach(node, (child) => {
    if (!isValidElement(child)) {
      return; // null / false / strings / numbers — stays silent
    }
    if (child.type === Fragment) {
      walk((child.props as { children?: ReactNode }).children);
      return;
    }
    const collector = overlayCollectors.find((c) =>
      isOverlayChild(child, c.overlayType, c.component),
    );
    if (collector == null) {
      warnOverlay(`MapView: ignoring child <${elementName(child)}> …`);
      return;
    }
    collector.collect(child, state);
  });
}

2. Warn in __DEV__ for everything else

Any valid element that is neither a Fragment nor matched by isOverlayChild gets a
console.warn through a helper shaped like warnGeojson.ts
(package/src/geojson/warnGeojson.ts). #125 / #128 propose the same warnOverlay helper
for invalid coordinates — share it. Suggested wording:

[react-native-better-maps] MapView: ignoring child <StoreMarkers>. Only <Marker>, <Polyline>,
<Polygon>, <Circle> and <Geojson> are collected, and they must be direct children (Fragments
are unwrapped, other components are not rendered). Return the elements from an array, or use
the bulk `markers` / `polylines` / `polygons` / `circles` props.

Name the offending type (displayName / name for functions, the tag for host elements)
and warn once per type per collection so a list of 500 stray elements does not flood the
console. null, undefined, booleans, strings and numbers stay silent — {cond && <Marker/>}
must not warn.

3. Document the contract

  • MapViewProps.children JSDoc (map.ts:46): which elements are collected, Fragments are
    unwrapped, other components are never rendered.
  • README quick start: one sentence under the snippet.
  • README react-native-maps migration table: a row Overlays inside custom components /
    render helpers
    → Not supported — return an array, or use the bulk props.
  • docs/architecture.md:105 and package/src/native/README.md:35: the same limit next to
    the "collected via React.Children" sentence.

Keep children?: ReactNode: a tighter element type cannot express "no wrappers" and would
reject the {cond && <Marker/>} idiom, so the warning is the guard.

TypeScript only — the descriptor structs and the Nitro view spec are untouched, no
nitrogen run.

Regression tests

The walk lives inside useMemo, so today it cannot be called from a bun test without a
React renderer, and the package has neither react-dom nor react-test-renderer. Move it
into a pure collectOverlayChildren(children, state) under package/src/overlays/ — next
to collectGeojsonOverlays.ts, which already has exactly this shape and its own tests — and
keep useCollectedOverlays as the memo wrapper. Then, with createElement alone, in
package/src/overlays/__tests__/collectOverlayChildren.test.ts:

  • a direct array and nested arrays collect everything, in order, with stable marker-N ids
  • <>…</> and a keyed <Fragment> inside an array collect their contents; a Fragment in a
    Fragment works; ids equal the direct-children case
  • a wrapper component collects nothing, is never invoked, and warns once with its name under
    __DEV__ = true; nothing is logged with __DEV__ unset
  • an unknown host element ('div') warns; null / false / strings do not
  • a mixed list keeps the collected neighbours of an ignored child

Notes

  • isOverlayChild also accepts any component carrying a static overlayType
    (overlayType.ts:17-19), added so reference equality survives Metro resolving the same
    component through two paths in a monorepo (overlayType.ts:3-6). It is internal and
    undocumented, and it is not a supported way to make wrappers collectable — the wrapper
    would have to take exactly MarkerProps, because it is never rendered. The warning must
    keep treating those elements as matched.
  • React's docs mark the Children API as legacy and recommend passing data instead of
    elements; the bulk markers / polylines / polygons / circles props are that path
    and are the right answer for anyone who hits the wrapper limit.
  • Custom React Native marker child views #34 (custom React Native marker views) is about the children of <Marker>; this issue is
    about the children of <MapView>.

Acceptance criteria

  • <MapView><>{markers}</></MapView> renders the same overlays, with the same ids, as
    the direct-children form
  • A keyed <Fragment> inside an array of overlays is unwrapped
  • <MapView><StoreMarkers/></MapView> logs one __DEV__ warning naming StoreMarkers;
    nothing is logged in production
  • {cond && <Marker/>}, null, strings and numbers stay silent
  • MapViewProps.children, the README quick start, the migration table and the
    architecture docs state the rule
  • The existing bun suite stays green and the new collector tests cover the table above

Related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmedReproduced and accepted by a maintainerdocumentationImprovements or additions to documentationgood first issueGood for newcomershelp wantedExtra attention is neededtypescriptThe TypeScript layer (package/src)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions