fix: apply the documented 'cover' default for an omitted resizeMode - #165
Open
giaBaoJS wants to merge 1 commit into
Open
fix: apply the documented 'cover' default for an omitted resizeMode#165giaBaoJS wants to merge 1 commit into
giaBaoJS wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #43
The problem
Omitting
resizeModegives you three different answers depending on who you ask β the docs saycover, iOS stretches, Android contains. There are five separate notions of "default" in the codebase and they all disagree:src/specs/ImageView.nitro.ts:38@default 'cover'nitrogen/generated/ios/c++/views/HybridNitroImageViewComponent.mm:88if (newViewProps.resizeMode.isDirty). An omitted prop is never dirty, sosetResizeMode(β¦)is never calledios/HybridImageView.swift:21/:42resizeMode ?? .coverresizeModeis a plain optional whosedidSetapplies the mapping. Because of (2) it is never assigned, soupdateResizeMode()never runs and the?? .coverfallback is dead codeios/Utils/CustomImageView.swift:23UIImageView's own defaultinit()setsclipsToBoundsbut notcontentMode, so it stays.scaleToFillβ this is the stretch users seeandroid/β¦/HybridImageView.kt:30/:63CONTAIN/null -> CENTER_CROPJHybridNitroImageViewStateUpdater.cpp:44has the sameisDirtygate.scaleTypestays atImageView's ownFIT_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), andprepareForRecycle()clears the image but not the resize mode. So a View that omitsresizeModedoesn'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:resizeModeView actually renderedresizeMode="contain"ViewcontainrenderresizeMode="center"ViewcenterrenderSo the effective default isn't merely wrong, it's non-deterministic.
This went unnoticed because
example/src/NitroImageTab.tsx:22passesresizeMode="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/Utils/CustomImageView.swift:contentMode = .scaleAspectFillininit().ios/HybridImageView.swift:resizeMode = nilinprepareForRecycle(). One line, and it routes through the existing?? .covermapping β which incidentally makes that previously-dead fallback meaningful again.HybridImageView.kt: field defaultCONTAINβCOVER, aninit { updateResizeMode() }so the existing mapping actually runs, andresizeMode = ResizeMode.COVERinprepareForRecycle().The construction default deliberately lives in
CustomImageView.swiftrather thanHybridImageView.swift.contentModeis 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 touchios/HybridImageView.swift(#116, #118, #126, #130, #131, #155) while none touchios/Utils/CustomImageView.swift. The recycle reset genuinely has to live inHybridImageView.swiftsince that's whereprepareForRecycle()is, but it's a single added line.I did not touch the
isDirtygates innitrogen/generated/**. They're arguably the deeper root cause β a prop with a documented@defaultis 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 withImages.loadFromRawPixelDataβ rendered into a 100Γ100 square tile, so the resize modes are actually distinguishable by pixels:stretchβ all four bands visible, 25px eachcoverβ scales Γ5 to 100Γ200 and centre-crops, so only the middle two bands (green, blue), 50px eachrenders an omitted resizeMode exactly like resizeMode="cover"β screenshots both tiles and compares them byte-for-byte, plus locks both against one sharedtoMatchImageSnapshotbaseline.still defaults to cover after a View that set a different resizeModeβ renders acontaintile first, then an omitted one, and asserts it still matchescover. This is the recycling case.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.Full suite, no regressions to the existing snapshots:
Counterfactual
I reverted the fix on each platform and confirmed the new tests fail, then restored it and confirmed they pass.
iOS, fix reverted:
Android, fix reverted:
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
resizeModetoday 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 iscoverand 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
resizeModeexplicitly or renders a solid-colour square.Caveats on my environment
26.2pinned inexample/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 thePixel_8_API_35in the config. Both config values are unchanged in this PR β I only overrode them locally.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)
ResizeModeisn't exported fromsrc/index.ts, even thoughresizeModeis a public documented prop β the test has to reach it viaNitroImageProps['resizeMode']. Happy to add the export in a separate PR if you want it.