Skip to content

fix: apply the documented 'cover' default for an omitted resizeMode - #165

Open
giaBaoJS wants to merge 1 commit into
mrousavy:mainfrom
giaBaoJS:fix/default-resize-mode-cover
Open

fix: apply the documented 'cover' default for an omitted resizeMode#165
giaBaoJS wants to merge 1 commit into
mrousavy:mainfrom
giaBaoJS:fix/default-resize-mode-cover

Conversation

@giaBaoJS

Copy link
Copy Markdown

Fixes #43

The problem

Omitting resizeMode gives you three different answers depending on who you ask β€” the docs say cover, iOS stretches, Android contains. There are five separate notions of "default" in the codebase and they all disagree:

# Location Says the default is Effect
1 src/specs/ImageView.nitro.ts:38 @default 'cover' the documented contract
2 nitrogen/generated/ios/c++/views/HybridNitroImageViewComponent.mm:88 β€” if (newViewProps.resizeMode.isDirty). An omitted prop is never dirty, so setResizeMode(…) is never called
3 ios/HybridImageView.swift:21 / :42 resizeMode ?? .cover resizeMode is a plain optional whose didSet applies the mapping. Because of (2) it is never assigned, so updateResizeMode() never runs and the ?? .cover fallback is dead code
4 ios/Utils/CustomImageView.swift:23 UIImageView's own default init() sets clipsToBounds but not contentMode, so it stays .scaleToFill β€” this is the stretch users see
5 android/…/HybridImageView.kt:30 / :63 CONTAIN / null -> CENTER_CROP Android disagrees with itself and with iOS. Neither takes effect: a Kotlin property initializer assigns the backing field directly so the setter never runs, and JHybridNitroImageViewStateUpdater.cpp:44 has the same isDirty gate. scaleType stays at ImageView's own FIT_CENTER (= contain)

There is a sixth case the issue doesn't mention, which I found while testing and verified on both platforms: NitroImage views are recycled (HybridNitroImageViewComponent.shouldBeRecycled β†’ RecyclableView), and prepareForRecycle() clears the image but not the resize mode. So a View that omits resizeMode doesn't just get the platform default β€” it inherits whatever resize mode the previous View that used that recycled instance had set. Measured on the iOS simulator, before the fix, with the striped fixture from this PR:

Render sequence What the omitted-resizeMode View actually rendered
(fresh view) stretch
after a resizeMode="contain" View contain β€” byte-identical to the contain render
after a resizeMode="center" View center β€” byte-identical to the center render

So the effective default isn't merely wrong, it's non-deterministic.

This went unnoticed because example/src/NitroImageTab.tsx:22 passes resizeMode="cover" explicitly, and because every existing view snapshot test renders a single-colour blank image β€” a solid rectangle looks identical under every resize mode, so no existing test could have caught this.

The fix

Apply the documented default where the prop system can't skip it: at construction, and again on recycle.

  • iOS β€” ios/Utils/CustomImageView.swift: contentMode = .scaleAspectFill in init().
  • iOS β€” ios/HybridImageView.swift: resizeMode = nil in prepareForRecycle(). One line, and it routes through the existing ?? .cover mapping β€” which incidentally makes that previously-dead fallback meaningful again.
  • Android β€” HybridImageView.kt: field default CONTAIN β†’ COVER, an init { updateResizeMode() } so the existing mapping actually runs, and resizeMode = ResizeMode.COVER in prepareForRecycle().

The construction default deliberately lives in CustomImageView.swift rather than HybridImageView.swift. contentMode is that view's own construction-time concern β€” and it also keeps the bulk of the change clear of a busy file: 6 of the 19 currently-open PRs touch ios/HybridImageView.swift (#116, #118, #126, #130, #131, #155) while none touch ios/Utils/CustomImageView.swift. The recycle reset genuinely has to live in HybridImageView.swift since that's where prepareForRecycle() is, but it's a single added line.

I did not touch the isDirty gates in nitrogen/generated/**. They're arguably the deeper root cause β€” a prop with a documented @default is never applied unless it is explicitly passed β€” but that belongs upstream in Nitrogen, not in a hand-edit to generated files.

Test

example/__tests__/resize-mode.harness.tsx, three cases. The fixture is a striped, non-square image β€” 20Γ—40 RGBA, four horizontal colour bands, built with Images.loadFromRawPixelData β€” rendered into a 100Γ—100 square tile, so the resize modes are actually distinguishable by pixels:

  • stretch β†’ all four bands visible, 25px each
  • cover β†’ scales Γ—5 to 100Γ—200 and centre-crops, so only the middle two bands (green, blue), 50px each
  1. renders an omitted resizeMode exactly like resizeMode="cover" β€” screenshots both tiles and compares them byte-for-byte, plus locks both against one shared toMatchImageSnapshot baseline.
  2. still defaults to cover after a View that set a different resizeMode β€” renders a contain tile first, then an omitted one, and asserts it still matches cover. This is the recycling case.
  3. distinguishes cover from stretch, so the assertions above are not vacuous β€” asserts two explicitly-set modes still differ, so the fixture can never silently stop discriminating and turn (1) and (2) into no-ops.

Results

Run with react-native-harness, on the iOS simulator and an Android emulator.

--- iOS ---     Tests: 3 passed, 3 total
--- Android --- Tests: 3 passed, 3 total

Full suite, no regressions to the existing snapshots:

iOS:     Test Suites: 8 passed, 8 total   Tests: 75 passed, 75 total
Android: Test Suites: 8 passed, 8 total   Tests: 1 skipped, 73 passed, 74 total

Counterfactual

I reverted the fix on each platform and confirmed the new tests fail, then restored it and confirmed they pass.

iOS, fix reverted:

● renders an omitted resizeMode exactly like resizeMode="cover"
  expected 3926 to be +0
● still defaults to cover after a View that set a different resizeMode
  expected 5178 to be +0
Tests: 2 failed, 1 passed, 3 total

Android, fix reverted:

● renders an omitted resizeMode exactly like resizeMode="cover"
  expected 1185 to be +0
● still defaults to cover after a View that set a different resizeMode
  expected 1185 to be +0
Tests: 2 failed, 1 passed, 3 total

In both cases case (3) correctly keeps passing β€” it compares two explicitly-set modes and is fix-independent.

Behaviour change

This is a behaviour change, not a pure bug fix, and it's worth stating plainly: anyone who omits resizeMode today and is β€” knowingly or not β€” relying on the accidental stretch on iOS or the accidental contain on Android will see their images render differently after this. I think that's right, since the documented contract is cover and this is the first time the two platforms agree with each other, but it deserves a changelog note and possibly a minor rather than a patch release.

Existing snapshots are unaffected (all 8 suites still pass on both platforms) because every current test either passes resizeMode explicitly or renders a solid-colour square.

Caveats on my environment

  • I ran the iOS suite against the iOS 26.3 simulator runtime, not the 26.2 pinned in example/rn-harness.config.mjs, because 26.3 is the only runtime installed on my machine. Android ran on a local API 36 arm64 emulator rather than the Pixel_8_API_35 in the config. Both config values are unchanged in this PR β€” I only overrode them locally.
  • Because of that, the two committed baseline PNGs were captured on those runtimes rather than on CI's. Their dimensions match the existing committed baselines exactly (iOS 300Γ—300, Android 263Γ—263), but to keep them from being brittle across runtimes the snapshot assertions use failureThreshold: 0.01 / failureThresholdType: 'percent'. That tolerance only absorbs anti-aliasing along the single colour boundary; a wrong resize mode moves roughly half the pixels. If you'd rather regenerate them on CI, deleting the two PNGs is safe β€” the byte-comparison assertions are what actually enforce the fix and need no baseline.

Aside (not changed here)

ResizeMode isn't exported from src/index.ts, even though resizeMode is a public documented prop β€” the test has to reach it via NitroImageProps['resizeMode']. Happy to add the export in a separate PR if you want it.

An omitted `resizeMode` prop is never marked dirty, so `setResizeMode(...)`
is never called and the view keeps whatever the platform's own default is -
`scaleToFill` (stretch) on iOS, `FIT_CENTER` (contain) on Android - instead
of the documented `cover`. Recycled views are worse still: they inherit the
resize mode the previous View set.

Apply the default where the prop system cannot skip it - at construction, and
again when a view is prepared for recycling.

Fixes mrousavy#43
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.

Default value of resizeMode (undefined) is stretching

1 participant