Skip to content

Scope the TextInput spannable cache to the React instance (#58869) - #58869

Open
zeyap wants to merge 1 commit into
react:mainfrom
zeyap:export-D122870300
Open

zeyap wants to merge 1 commit into
react:mainfrom
zeyap:export-D122870300

Conversation

@zeyap

@zeyap zeyap commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Fabric, ReactEditText caches its spannable in TextLayoutManager keyed by react tag, and the shadow node's state keeps that tag as cachedAttributedStringId to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old ReactEditText is finalized it removes the new view's entry. The next cached measure then fails checkNotNull in getOrCreateSpannableForText with "Required value was null".

This change gives each React instance its own spannable cache:

  • TextLayoutManager keeps one map per ReactApplicationContext. FabricUIManager creates it in its constructor and removes it in invalidate().
  • FabricUIManager.measureText passes its own context, so a cached measure only looks in that instance's map.
  • ReactEditText writes to and evicts from the map of the context it was created with (ThemedReactContext.reactApplicationContext). Create, destroy, read and write all resolve the key through one helper, so a view's ThemedReactContext and FabricUIManager's ReactApplicationContext always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
  • A layout that started before invalidate() can still reach measureText after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id MapBuffer carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. The same applies when finalize() has already evicted the tag while a background commit still measures it: the measure returns 0x0 instead of failing checkNotNull.

This replaces the previous version of this diff, where finalize() only evicted the spannable the instance itself had cached. Per-tag eviction still happens in finalize(), but now only within the view's own instance, where tags are never reused. I did not move eviction to onDropViewInstance: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would make that case much more common.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 5, 2026
@facebook-github-tools facebook-github-tools Bot added p: Facebook Partner: Facebook Partner labels Oct 5, 2026
@meta-codesync

meta-codesync Bot commented Oct 5, 2026

Copy link
Copy Markdown

@zeyap has exported this pull request. If you are a Meta employee, you can view the originating Diff in D122870300.

@meta-codesync meta-codesync Bot changed the title Scope the TextInput spannable cache to the React instance Scope the TextInput spannable cache to the React instance (#58869) Oct 5, 2026
zeyap pushed a commit to zeyap/react-native that referenced this pull request Oct 5, 2026
Summary:

On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure uses an empty spannable instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from aa811de to ecc0b42 Compare October 5, 2026 17:36
zeyap pushed a commit to zeyap/react-native that referenced this pull request Oct 6, 2026
Summary:

On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure uses an empty spannable instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from ecc0b42 to f9dd1fe Compare October 6, 2026 00:31
zeyap pushed a commit to zeyap/react-native that referenced this pull request Oct 6, 2026
Summary:

On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from f9dd1fe to 748c414 Compare October 6, 2026 01:00
zeyap pushed a commit to zeyap/react-native that referenced this pull request Oct 6, 2026
Summary:
Pull Request resolved: react#58869

On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from 748c414 to 53eeb98 Compare October 6, 2026 01:04
zeyap pushed a commit to zeyap/react-native that referenced this pull request Oct 6, 2026
Summary:
WARNING: Generated by Autopilot (alpha) — review carefully, verify the underlying claim before accepting.
Agent: React Native Agent (Bugs) | Trajectory: https://www.internalfb.com/intern/devai/devmate/inspector/49205bed-501a-43cd-9059-1e84fb06a6ba/ | SC job: https://www.internalfb.com/intern/sandcastle/instance/45035996995515977/

---


On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from 53eeb98 to bd0c61b Compare October 6, 2026 01:26
zeyap pushed a commit to zeyap/react-native that referenced this pull request Oct 6, 2026
Summary:
WARNING: Generated by Autopilot (alpha) — review carefully, verify the underlying claim before accepting.
Agent: React Native Agent (Bugs) | Trajectory: https://www.internalfb.com/intern/devai/devmate/inspector/dea12476-b7e6-441b-8ebb-bd79b2de520a/ | SC job: https://www.internalfb.com/intern/sandcastle/instance/49539596621165635/

 ---

Pull Request resolved: react#58869

On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from bd0c61b to 307162b Compare October 6, 2026 01:32
zeyap pushed a commit to zeyap/react-native that referenced this pull request Oct 6, 2026
Summary:
WARNING: Generated by Autopilot (alpha) — review carefully, verify the underlying claim before accepting.
Agent: React Native Agent (Bugs) | Trajectory: https://www.internalfb.com/intern/devai/devmate/inspector/dea12476-b7e6-441b-8ebb-bd79b2de520a/ | SC job: https://www.internalfb.com/intern/sandcastle/instance/49539596621165635/

---


On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from 307162b to f67f239 Compare October 6, 2026 01:46
Summary:
Pull Request resolved: react#58869

On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null".

This change gives each React instance its own spannable cache:
- `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`.
- `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map.
- `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone.
- A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. The same applies when `finalize()` has already evicted the tag while a background commit still measures it: the measure returns 0x0 instead of failing `checkNotNull`.

This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would make that case much more common.

Changelog:
[Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance

Reviewed By: zeyap

Differential Revision: D122870300
@zeyap
zeyap force-pushed the export-D122870300 branch from f67f239 to 4abba0c Compare October 6, 2026 01:50

This branch has not been deployed

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported p: Facebook Partner: Facebook Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant