fix: stop re-serializing overlay props on unrelated re-renders - #58
fix: stop re-serializing overlay props on unrelated re-renders#58jkasprzyk17 wants to merge 3 commits into
Conversation
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 Doctor found 8 issues in 5 files · 2 errors & 6 warnings · score 64 / 100 (Needs work) · full project Errors
6 warnings
Reviewed by React Doctor for commit |
| const previous = useRef(next); | ||
| const stable = isEqual(previous.current, next) ? previous.current : next; | ||
|
|
||
| previous.current = stable; |
There was a problem hiding this comment.
React Doctor · react-doctor/no-ref-current-in-render (error)
This ref is mutated during render. React can replay or discard render work, so the mutation can leak from UI that never commits.
Fix → Move ref writes into an event handler or effect. Render must stay pure because React can replay or discard it. The predictable null-guarded lazy initialization pattern remains supported.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
Included review availability: 2 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour. 📝 WalkthroughSummary by CodeRabbit
Walkthrough
ChangesMapView stability
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The change prevents unnecessary overlay updates while preserving propagation of genuine changes, with documented immutability behavior and passing validation checks; no actionable merge-blocking risk remains. Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant MapView
participant useStableValue
participant useNitroCallback
participant NativeMapView
MapView->>useStableValue: compare overlay and animation descriptors
useStableValue-->>MapView: return committed stable values
MapView->>useNitroCallback: memoize native event handlers
useNitroCallback-->>MapView: return Nitro callback envelopes
MapView->>NativeMapView: pass stable props and callbacks
``
</details>
<!-- walkthrough_end -->
<!-- pre_merge_checks_walkthrough_start -->
---
<!-- pre_merge_checks_override_start -->
> [!CAUTION]
> ## Pre-merge checks failed
>
> Please resolve all errors before merging. Addressing warnings is optional.
>
> - [ ] <!-- {"checkboxId":"override-pre-merge-checks"} --> Ignore
<!-- pre_merge_checks_override_end -->
### ❌ Failed checks (1 error)
| Check name | Status | Explanation | Resolution |
| :------------: | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Security Check | ❌ Error | High-confidence supply-chain vulnerability: the new release workflow runs attacker-controlled repository code with release credentials. `workflow_dispatch` has no branch restriction, and `actions/chec… | Restrict rehearsal runs to a trusted ref, such as the protected default branch, and reject all other `workflow_dispatch` refs before checkout or before any repository code executes. Prefer separate jobs: run validation with read-only permis… |
<details>
<summary>Full details: Security Check</summary>
**Explanation**
High-confidence supply-chain vulnerability: the new release workflow runs attacker-controlled repository code with release credentials. `workflow_dispatch` has no branch restriction, and `actions/checkout` uses the selected ref. A user with repository write access can push a malicious branch and dispatch this workflow against it. The workflow then runs repository-controlled install, codegen, build, and test commands. The publish step also exposes `GITHUB_TOKEN` while executing `bunx release-it`, which loads repository-controlled package scripts and release hooks. The job grants `contents: write` and `id-token: write`. The manual-run `--dry-run` flag does not sandbox earlier commands or release hooks. This permits exfiltration or misuse of the GitHub token and OIDC-based publishing credentials. Repository guidance says manual runs use the current branch, and the workflow contains no protected environment or trusted-ref check.
**Resolution**
Restrict rehearsal runs to a trusted ref, such as the protected default branch, and reject all other `workflow_dispatch` refs before checkout or before any repository code executes. Prefer separate jobs: run validation with read-only permissions, then run publishing only from a protected tag or reviewed commit after an approval-gated environment. Do not expose `GITHUB_TOKEN` or OIDC permissions to jobs that execute checked-out repository code. If release hooks must run, use a trusted release configuration and pin the source commit rather than loading configuration from the manually selected branch.
</details>
<!-- pre_merge_checks_walkthrough_end -->
<!-- tips_start -->
---
<sub>Comment `@coderabbitai help` to get the list of available commands.</sub>
<!-- tips_end -->
|
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.
Problem
Nitro diffs view props by reference identity (
jsi::Value::strictEquals), butMapViewrebuilt every overlay array, everycallback(...)envelope and both entering-animation descriptors on each render. A re-render of the component holding theMapView— a timer, an unrelated state tick — therefore marked props dirty even when the map data was byte-identical.Measured by rendering
MapViewtwice with identical data and diffing the props exactly as Nitro does:<Marker />children + all handlers + entering animations<Marker />children, nothing elsemarkers, caller-memoized, one handler<MapView style={STYLE} />Each dirty prop cost a full
JSIConverterpass on the JS thread. For markers that is 13getPropertycalls per descriptor plus nested reads (~27 for a marker with an image, anchor, offset and animation) and astd::stringallocation per text field — then a C++→Swift array materialisation and a native re-apply.hybridRefbeing dirty also re-invoked the JS ref callback through the dispatcher on every render, even for aMapViewwith no props at all.Approach
The whole fix is controlling when the reference changes:
useStableValueholds 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.descriptorEqualityprovides the comparators: field-by-field for markers, polylines, polygons and circles, plus coordinate lists and entering animations.useNitroCallbackmemoises the{ f }envelope on the handler it wraps instead of allocating one per render;hybridRef's envelope is created once per mount.normalizeMarkerDescriptorsdrops its own identity-preservation (and the partly brokendescriptorsEqualbehind 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
MapViewis 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
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.tsreworked from identity assertions onto value assertions (require()resolution, resolution caching, animation normalisation) — the identity behaviour it pinned no longer exists.react-test-rendererthat 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, 129srctests and the plugin suite all pass.Not included
Shape overlays (
polylines/polygons/circles) are still torn down and rebuilt natively on every set —MapOverlayControllerhas no equality guard for them the way markers do. That is now unreachable fromMapViewbecause this change never sets an unchanged shape prop, but it is worth fixing on its own. It should be built on therenderSignature()helper that landed in #54 rather than a bespoke fold; I have a draft that predates that commit and needs rewriting against it.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.