Skip to content

[iOS] Image.compress numeric options (maxWidth/maxHeight/quality) are silently ignored — Nitro passes Double, as? Int always fails #417

Description

@umutcakmaks

Description

On iOS, every numeric option passed to Image.compress() is silently ignored and replaced by the internal default (maxWidth/maxHeight1280, quality0.8). Values passed from JS have no effect at all, and there is no warning or error.

Android is unaffected. Video compression is unaffected.

Reproduction

import { Image } from 'react-native-compressor';

const uri = await Image.compress(sourceUri, {
  compressionMethod: 'manual',
  maxWidth: 1920,
  maxHeight: 1920,
  quality: 0.8,
  returnableOutputType: 'uri',
});

With a 2922×3896 source:

  • expected output: 1440×1920
  • actual output: 960×1280 — i.e. bounded by 1280, the internal default

Passing maxWidth: 200 / maxHeight: 200 produces the same 1280-bounded result, which is how I first noticed it (a blurhash thumbnail step was ~40× more expensive than intended).

Root cause

ios/Image/ImageCompressorOptions.swift:

case "maxWidth":
    options.maxWidth = (value as? Int) ?? 1280
case "maxHeight":
    options.maxHeight = (value as? Int) ?? 1280
case "progressDivider":
    options.progressDivider = (value as? Int) ?? 0
case "quality":
    options.quality = (value as? Float) ?? 0.8

Nitro represents JS numbers as DoubleAnyValue.number(Double), and toAny() returns that Double unchanged. HybridCompressor.dictionary(from:) then runs it through normalize() and an as NSDictionary round-trip, but neither of those boxes the value into NSNumber; it stays a Swift Double.

A conditional downcast from Any holding a Double to Int or Float always fails in Swift (no implicit numeric narrowing, and no bridging path from a raw Swift Double), so all four cases fall through to their ?? defaults.

Verified with a standalone Swift snippet that replicates dictionary(from:):

let fromAnyMap: [String: Any] = ["maxWidth": Double(1920), "quality": Double(0.8)]
let roundTripped = ((fromAnyMap as NSDictionary) as? [String: Any])!
// maxWidth: type=Double
//    as? Int                 -> nil
//    as? Float               -> nil
//    (as? NSNumber)?.intValue -> Optional(1920)

Video is unaffected because ios/Video/VideoMain.swift reads through NSNumber:

let maxSize = (options["maxSize"] as? NSNumber)?.floatValue ?? Float(1920)
let bitRate = (options["bitrate"] as? NSNumber)?.intValue

Double as? NSNumber succeeds, so that path behaves correctly. The two files just use different conventions.

Android is unaffected because ImageCompressorOptions.kt uses map.getInt(key) / map.getDouble(key).toFloat(), which coerce properly. The practical effect is that the same JS call produces different output dimensions on iOS and Android.

Affected options

maxWidth, maxHeight, quality, progressDivider — iOS only.

Suggested fix

Read through NSNumber, matching what the video side already does:

case "maxWidth":
    options.maxWidth = (value as? NSNumber)?.intValue ?? 1280
case "maxHeight":
    options.maxHeight = (value as? NSNumber)?.intValue ?? 1280
case "progressDivider":
    options.progressDivider = (value as? NSNumber)?.intValue ?? 0
case "quality":
    options.quality = (value as? NSNumber)?.floatValue ?? 0.8

I verified this handles all the cases:

  • Double from Nitro → correct value
  • Int / Float, if a caller ever passes one → still correct (backward compatible)
  • key absent → existing default preserved

One detail: quality must use .floatValue, not .intValue0.8 would truncate to 0.

I'm running this as a patch-package patch locally and it resolves the issue. Happy to open a PR if that's useful.

Environment

react-native-compressor 2.0.3
react-native-nitro-modules 0.35.3
React Native 0.84.1 (new architecture)
Platform iOS

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions