Skip to content

fix: stop re-serializing overlay props on unrelated re-renders - #58

Merged
jkasprzyk17 merged 4 commits into
mainfrom
fix/overlay-reserialization-on-rerender
Sep 12, 2026
Merged

fix: stop re-serializing overlay props on unrelated re-renders#58
jkasprzyk17 merged 4 commits into
mainfrom
fix/overlay-reserialization-on-rerender

Conversation

@jkasprzyk17

@jkasprzyk17 jkasprzyk17 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Nitro diffs view props by reference identity (jsi::Value::strictEquals), but MapView rebuilt every overlay array, every callback(...) envelope and both entering-animation descriptors on each render. A re-render of the component holding the MapView — a timer, an unrelated state tick — therefore marked props dirty even when the map data was byte-identical.

Measured by rendering MapView twice with identical data and diffing the props exactly as Nitro does:

scenario before after
<Marker /> children + all handlers + entering animations 19 dirty props 0
<Marker /> children, nothing else 5 0
bulk markers, caller-memoized, one handler 2 0
bare <MapView style={STYLE} /> 1 0

Each dirty prop cost a full JSIConverter pass on the JS thread. For markers that is 13 getProperty calls per descriptor plus nested reads (~27 for a marker with an image, anchor, offset and animation) and a std::string allocation per text field — then a C++→Swift array materialisation and a native re-apply. hybridRef being dirty also re-invoked the JS ref callback through the dispatcher on every render, even for a MapView with no props at all.

Approach

The whole fix is controlling when the reference changes:

  • useStableValue holds the last returned value in a ref and hands it back when the new one is structurally equal. One primitive, N comparators — it covers the four overlay arrays and both entering-animation descriptors.
  • descriptorEquality provides the comparators: field-by-field for markers, polylines, polygons and circles, plus coordinate lists and entering animations.
  • useNitroCallback memoises the { f } envelope on the handler it wraps instead of allocating one per render; hybridRef's envelope is created once per mount.
  • normalizeMarkerDescriptors drops its own identity-preservation (and the partly broken descriptorsEqual behind it) — that job now belongs to the stabilizer, which does it correctly for every field.

When something genuinely changes, the comparator returns false, the reference changes, and the chain runs exactly as before. Nothing is skipped or deferred.

Behaviour change

Descriptors and the objects inside them are now treated as immutable. Mutating a coordinate you already handed to MapView is not picked up, because the comparison sees the same object on both sides — with <Marker /> children that previously happened to work. Documented in the README with an example, and pinned by a test so it stays a deliberate contract.

Testing

  • 73 new tests in descriptorEquality.test.ts. Every field of every descriptor has its own mutation case: a field a comparator misses is a map update that silently never reaches native, so the coverage is exhaustive by construction. Also pinned: the four near-identical list wrappers each reach their own item comparator, and the shared-nested-object contract above.
  • normalizeMarkerDescriptors.test.ts reworked from identity assertions onto value assertions (require() resolution, resolution caching, animation normalisation) — the identity behaviour it pinned no longer exists.
  • Separately verified with react-test-renderer that real changes still propagate: moving a marker, renaming a title, adding/removing a marker, editing a polyline coordinate, swapping a handler, changing an entering animation, and switching between bulk props and children.
  • bun run lint, bun run typecheck, bun run build, 129 src tests and the plugin suite all pass.

Not included

Shape overlays (polylines / polygons / circles) are still torn down and rebuilt natively on every set — MapOverlayController has no equality guard for them the way markers do. That is now unreachable from MapView because this change never sets an unchanged shape prop, but it is worth fixing on its own. It should be built on the renderSignature() helper that landed in #54 rather than a bespoke fold; I have a draft that predates that commit and needs rewriting against it.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

React Doctor found 6 issues in 3 files · 2 errors & 4 warnings · score 64 / 100 (Needs work) · full project

Errors

4 warnings

App.tsx

  • ⚠️ L727 Side effect inside a state updater function no-side-effect-in-state-updater-function
  • ⚠️ L732 Side effect inside a state updater function no-side-effect-in-state-updater-function
  • ⚠️ L733 Side effect inside a state updater function no-side-effect-in-state-updater-function

src/components/MapView.tsx

  • ⚠️ L46 React function has high control-flow complexity no-high-complexity-react-function

Reviewed by React Doctor for commit 328a997. See inline comments for fixes.

Comment thread package/src/hooks/useStableValue.ts Outdated
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Essentials

Run ID: 57d9a209-a798-43e2-8761-0231601507b2

📥 Commits

Reviewing files that changed from the base of the PR and between 95528d3 and 01629a9.

📒 Files selected for processing (5)
  • README.md
  • package/src/overlays/__tests__/descriptorEquality.test.ts
  • package/src/overlays/__tests__/normalizeMarkerDescriptors.test.ts
  • package/src/overlays/descriptorEquality.ts
  • package/src/overlays/normalizeMarkerDescriptors.ts

Included review availability: 3 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.


📝 Summary

Summary by CodeRabbit

  • Performance

    • Improved map rendering stability by reducing unnecessary overlay and animation updates.
    • Improved event-handler stability for smoother native map interactions.
  • Bug Fixes

    • Fixed committed-value handling during interrupted renders.
    • Improved detection of meaningful changes to map overlays, coordinates, styling, and animations, including marker colors and stacking order.
  • Documentation

    • Added GeoJSON overlay documentation, supported geometry details, styling guidance, limitations, migration notes, API references, and examples.
    • Added guidance on reference-based prop comparisons, event handlers, immutable updates, and updating marker data.

Walkthrough

MapView now preserves stable overlay, animation, reference, and event-handler values across equivalent renders. New equality utilities, React hooks, normalization tests, and GeoJSON documentation support this behavior.

Changes

MapView stability

Layer / File(s) Summary
Overlay comparison and normalization
package/src/overlays/descriptorEquality.ts, package/src/overlays/normalizeMarkerDescriptors.ts, package/src/overlays/__tests__/*, package/src/utils/__tests__/enteringAnimation.test.ts
Structural comparators cover overlay fields, nested values, lists, and entering animations. Marker normalization always maps descriptors to normalized values. Tests cover equality, ordering, mutation, image resolution, and animation mappings.
Stable callback and value hooks
package/src/hooks/useNitroCallback.ts, package/src/hooks/useStableValue.ts
useNitroCallback memoizes Nitro callback envelopes. useStableValue commits updated values in a layout effect and retains the previous committed value during discarded renders.
MapView native prop stabilization
package/src/components/MapView.tsx
MapView stabilizes overlay and animation descriptors and memoizes native handlers and the hybrid ref callback.

GeoJSON documentation

Layer / File(s) Summary
GeoJSON overlay guide and API references
README.md
The README documents GeoJSON geometry mappings, styling, conversion utilities, limitations, migration guidance, public API entries, capability coverage, and documentation links.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Unblocks: 6 PRs

Merge Risk: ⚪ Minimal · up to 328a9

The reviewed changes do not show a concrete merge-blocking risk.

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.76% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required fix: prefix and accurately describes the main change. It is 62 characters, which exceeds the preferred 50-character target, but the length guideline is not strict.
Description check ✅ Passed The description clearly explains the re-serialization problem, the stabilization approach, behavior changes, testing, and scope limitations. It is directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed No medium-or-higher security vulnerability was introduced. The authoritative diff changes React prop stabilization, callback memoization, descriptor normalization/equality, documentation, and tests. T…
Full details: Docstring Coverage

Explanation

Docstring coverage is 11.76% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 4 files. (1 skipped: 1 unsupported.)


Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 24, 2026
jkasprzyk17 added a commit that referenced this pull request Sep 8, 2026
…tic clustering

Native-side fixes for work the marker and overlay pipeline was doing on every
render or every gesture, plus value equality for the camera props on the JS
side. Builds on #58, which stabilizes the overlay arrays and callback
envelopes.

- Value-compare region, camera and mapPadding before they reach native, so an
  inline object literal no longer re-sends the prop (and, on the Google
  providers, no longer moves the camera) on every render.
- Keep a render version per shape overlay on MapKit, Google iOS and Android:
  an unchanged polyline, polygon or circle is skipped and a changed one is
  updated in place instead of removed and re-added. MapKit replaces the
  overlay at its previous z-position only when the geometry changed.
- Coalesce viewport refreshes to one pending request per compute queue and
  check a separate dataset generation before building the spatial index, so a
  long gesture cannot build a backlog of stale cluster work or keep discarding
  the index build for a dataset that has not changed.
- Skip region fits that would not move the camera on Google iOS and Android.
- Bound the marker image caches by decoded bytes (iOS NSCache limits, Android
  LruCache sizeOf).
- iOS: accumulate cluster buckets in place through the dictionary subscript;
  the copy-out, append, write-back pattern copied the member array on every
  append, O(k^2) per cell.
- iOS: drop the forced layoutIfNeeded() per pin configure inside MapKit's
  viewFor callback.
Nitro diffs view props by reference identity, but MapView rebuilt every
overlay array, callback envelope and entering-animation descriptor on each
render. A parent state change with unchanged map data marked up to 19 props
dirty, paying a full JSI conversion of the descriptor arrays on the JS thread
plus a native re-apply of every one of them.

Compare descriptors structurally and hand back the previous value when nothing
changed, memoize the callback(...) envelopes on the handler they wrap, and
create the hybridRef envelope once per mount. An unchanged re-render now
reaches native with zero dirty props.

normalizeMarkerDescriptors no longer tries to preserve array identity; that job
now belongs to the stabilizer, which does it correctly for every field.

Descriptors are treated as immutable: mutating an object already handed to
MapView is not picked up, because the comparison sees the same object on both
sides.
Describe what MapView memoizes on the caller's behalf, what is left for the
caller to hoist, and the immutability requirement the structural comparison
implies.
React can discard a render, and a ref written during one would leave
useStableValue remembering a value the native view never received. Move the
write into a layout effect so it only records committed renders.

The effect is a no-op on the renders this hook exists for: an unchanged value
leaves `stable` - and with it the dependency - untouched, so nothing re-runs.

Also drop the hooks barrel additions. Nothing imports that barrel, and MapView
reaches the hooks by path like it already did for useCollectedOverlays.
@jkasprzyk17
jkasprzyk17 force-pushed the fix/overlay-reserialization-on-rerender branch from 01629a9 to 328a997 Compare September 11, 2026 11:53
@jkasprzyk17
jkasprzyk17 merged commit d0e2ea7 into main Sep 12, 2026
6 checks passed
jkasprzyk17 added a commit that referenced this pull request Sep 12, 2026
…tic clustering

Native-side fixes for work the marker and overlay pipeline was doing on every
render or every gesture, plus value equality for the camera props on the JS
side. Builds on #58, which stabilizes the overlay arrays and callback
envelopes.

- Value-compare region, camera and mapPadding before they reach native, so an
  inline object literal no longer re-sends the prop (and, on the Google
  providers, no longer moves the camera) on every render.
- Keep a render version per shape overlay on MapKit, Google iOS and Android:
  an unchanged polyline, polygon or circle is skipped and a changed one is
  updated in place instead of removed and re-added. MapKit replaces the
  overlay at its previous z-position only when the geometry changed.
- Coalesce viewport refreshes to one pending request per compute queue and
  check a separate dataset generation before building the spatial index, so a
  long gesture cannot build a backlog of stale cluster work or keep discarding
  the index build for a dataset that has not changed.
- Skip region fits that would not move the camera on Google iOS and Android.
- Bound the marker image caches by decoded bytes (iOS NSCache limits, Android
  LruCache sizeOf).
- iOS: accumulate cluster buckets in place through the dictionary subscript;
  the copy-out, append, write-back pattern copied the member array on every
  append, O(k^2) per cell.
- iOS: drop the forced layoutIfNeeded() per pin configure inside MapKit's
  viewFor callback.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants