Skip to content

fix(nitrogen): match Kotlin's is prefix rule for JVM accessors - #1580

Merged
mrousavy merged 2 commits into
margelo:mainfrom
giaBaoJS:fix/kotlin-is-prefix-accessors
Sep 7, 2026
Merged

fix(nitrogen): match Kotlin's is prefix rule for JVM accessors#1580
mrousavy merged 2 commits into
margelo:mainfrom
giaBaoJS:fix/kotlin-is-prefix-accessors

Conversation

@giaBaoJS

@giaBaoJS giaBaoJS commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What breaks

packages/nitrogen/src/syntax/Property.ts:103 (getSetterName) and :74 (getGetterName) modelled Kotlin's is prefix rule as "the property is a boolean and name.startsWith('is')".

Kotlin's actual rule (JvmAbi.startsWithIsPrefix) is different in two ways:

  1. The character after is must not be a lowercase letter. isBlue is shortened, isolatedBoolean is not.
  2. It applies to every property type, not just Boolean. var isTextValue: String is shortened too.

Because the JNI bridge looks methods up by name at runtime, a mismatch is not caught by the Android build. It throws on first access:

Kotlin property kotlinc/javap emits nitrogen generated (before)
var isolatedBoolean: Boolean getIsolatedBoolean() / setIsolatedBoolean(..) isolatedBoolean() / setOlatedBoolean(..)
var isTextValue: String isTextValue() / setTextValue(..) getIsTextValue() / setIsTextValue(..)

Note setOlatedBoolean: the old code did name.replace('is', ''), so isolatedBoolean became OlatedBoolean.

iOS is unaffected. Swift/C++ interop really does use a plain is/has prefix check and really is boolean only, so that branch is kept as it was, just moved out of the shared boolean gate.

Fix

Property.ts now has a single hasJvmIsPrefix() helper that mirrors JvmAbi.startsWithIsPrefix, used by both the JVM getter and setter name. The Swift branch keeps its own (boolean, is/has) rule.

Test

Two properties on SharedTestObjectProps (next to the existing has*/is* block added in #1223), one per shape, plus a set + get assertion for each in apps/example/src/getTests.ts, so the Harness workflows cover them on both platforms:

isolatedBoolean: boolean
isTextValue: string

isTextValue rather than isStringValue, because isStringValue collides with the existing stringValue property on the same class (both would be setStringValue(String), which kotlinc rejects with a platform declaration clash).

How I proved it

Ground truth from the real Kotlin compiler, on the property declarations nitrogen generates in HybridTestObjectSwiftKotlinSpec.kt:

$ javap -cp outspec Spec
public abstract class Spec {
  public abstract java.lang.String getStringValue();
  public abstract void setStringValue(java.lang.String);
  public abstract boolean getHasBoolean();
  public abstract boolean isBoolean();
  public abstract boolean getHasBooleanWritable();
  public abstract void setHasBooleanWritable(boolean);
  public abstract boolean isBooleanWritable();
  public abstract void setBooleanWritable(boolean);
  public abstract boolean getIsolatedBoolean();
  public abstract void setIsolatedBoolean(boolean);
  public abstract java.lang.String isTextValue();
  public abstract void setTextValue(java.lang.String);
}

With the fix, bun specs generates exactly those names in JHybridTestObjectSwiftKotlinSpec.cpp:

getMethod<jboolean()>("getIsolatedBoolean");
getMethod<void(jboolean /* isolatedBoolean */)>("setIsolatedBoolean");
getMethod<jni::local_ref<jni::JString>()>("isTextValue");
getMethod<void(jni::alias_ref<jni::JString> /* isTextValue */)>("setTextValue");

Reverting only Property.ts and re-running bun specs gives four lookups that do not exist on the Kotlin class:

getMethod<jboolean()>("isolatedBoolean");
getMethod<void(jboolean /* isolatedBoolean */)>("setOlatedBoolean");
getMethod<jni::local_ref<jni::JString>()>("getIsTextValue");
getMethod<void(jni::alias_ref<jni::JString> /* isTextValue */)>("setIsTextValue");

I also checked the iOS side against swiftc -cxx-interoperability-mode=default -emit-clang-header-path, which emits isolatedBoolean() / setIsolatedBoolean(..) and getIsTextValue() / setIsTextValue(..), matching the generated HybridTestObjectSwiftKotlinSpecSwift.hpp.

Checks

  • bun specs run, generated files committed. Apart from the two new properties, no generated output changed.
  • bun run build, bun typecheck, bun lint, bun lint-ci in every package, bun lint-cpp, bun lint-swift, bun lint-kotlin: all clean, no files changed by the formatters.
  • I could not run the Harness suite (no device available here), so the two new runtime assertions are verified by CI, not locally.

Breaking changes

A Nitro module with a Kotlin is* property in one of these two shapes changes its generated JNI lookup names. That code could not have worked before, so nothing that currently runs is affected.

@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
nitro-docs Skipped Skipped Sep 7, 2026 11:11am UTC

Request Review

@nitro-modules-bot

Copy link
Copy Markdown

Performance Report

⚠️ Advisory: Results do not fail this PR while the baseline is being calibrated.

iOS

Benchmark Before After Difference
C++ copy(1 MiB) 128.42 µs 96.93 µs 🟢 -24.52% faster
C++ synchronousCallback() 404.6 ns 325.6 ns 🟢 -19.52% faster
C++ number | string variant 246.0 ns 205.9 ns 🟢 -16.31% faster
Swift copy(1 MiB) 112.92 µs 93.36 µs 🟢 -17.32% faster
Swift copy(4 KiB) 2.07 µs 1.54 µs 🟢 -25.59% faster
Swift large array (1,024) 84.99 µs 67.79 µs 🟢 -20.24% faster
Swift synchronousCallback() 462.3 ns 419.9 ns 🟢 -9.16% faster
Swift create() 44.43 µs 38.21 µs 🟢 -14% faster
Swift simpleFunc() 106.8 ns 122.1 ns 🔴 +14.25% slower
Swift number | string variant 409.3 ns 312.9 ns 🟢 -23.54% faster
All Benchmarks
Benchmark Before After Difference
JavaScript addNumbers() 38.1 ns 39.6 ns 🟡 +3.98% slower (noisy)
C++ bounce(1 MiB) 389.9 ns 386.4 ns 🟡 -0.88% faster (noisy)
C++ bounce(4 KiB) 465.7 ns 421.8 ns 🟡 -9.43% faster (noisy)
C++ copy(4 KiB) 1.71 µs 1.47 µs 🟡 -13.8% faster (noisy)
C++ large array (1,024) 66.99 µs 62.77 µs 🟡 -6.3% faster (noisy)
C++ small array (16) 1.27 µs 1.25 µs 🟡 -1.27% faster (noisy)
C++ create() 676.9 ns 687.9 ns 🟡 +1.62% slower (noisy)
C++ returnExisting() 321.9 ns 340.7 ns 🟡 +5.84% slower (noisy)
C++ typed map (8 entries) 2.84 µs 2.93 µs 🟡 +3.15% slower (noisy)
C++ optional trailing string 268.3 ns 278.1 ns 🟡 +3.65% slower (noisy)
C++ addNumbers() 146.3 ns 144.1 ns 🟡 -1.48% faster (noisy)
C++ simpleFunc() 120.3 ns 133.7 ns 🟡 +11.09% slower (noisy)
C++ immediatePromise() 1.36 µs 1.42 µs ℹ️ +4.38% slower (advisory)
C++ number property get/set 234.9 ns 215.9 ns 🟡 -8.12% faster (noisy)
C++ short ASCII string 251.9 ns 204.4 ns 🟡 -18.83% faster (noisy)
C++ Unicode string 462.9 ns 480.0 ns 🟡 +3.7% slower (noisy)
C++ nested Car struct 3.58 µs 3.33 µs 🟡 -6.97% faster (noisy)
Swift bounce(1 MiB) 508.3 ns 487.5 ns 🟡 -4.1% faster (noisy)
Swift bounce(4 KiB) 529.3 ns 465.6 ns 🟡 -12.04% faster (noisy)
Swift small array (16) 1.52 µs 1.48 µs 🟡 -3.08% faster (noisy)
Swift returnExisting() 497.8 ns 419.6 ns 🟡 -15.72% faster (noisy)
Swift typed map (8 entries) 3.45 µs 3.37 µs 🟡 -2.31% faster (noisy)
Swift optional trailing string 291.1 ns 260.4 ns 🟡 -10.55% faster (noisy)
Swift addNumbers() 133.3 ns 166.0 ns 🟡 +24.54% slower (noisy)
Swift immediatePromise() 2.36 µs 2.05 µs ℹ️ -13.24% faster (advisory)
Swift number property get/set 264.1 ns 332.7 ns 🟡 +26% slower (noisy)
Swift short ASCII string 293.5 ns 311.9 ns 🟡 +6.26% slower (noisy)
Swift Unicode string 657.4 ns 615.4 ns 🟡 -6.39% faster (noisy)
Swift nested Car struct 3.71 µs 3.24 µs 🟡 -12.62% faster (noisy)
TurboModule addNumbers() 1.56 µs 1.63 µs 🟡 +4.38% slower (noisy)

Android

Performance is unchanged! 😎

All Benchmarks
Benchmark Before After Difference
JavaScript addNumbers() 43.7 ns 43.6 ns ⚪ ~0.18% unchanged
C++ bounce(1 MiB) 619.7 ns 626.5 ns 🟡 +1.1% slower (noisy)
C++ bounce(4 KiB) 621.6 ns 602.7 ns 🟡 -3.05% faster (noisy)
C++ copy(1 MiB) 33.32 µs 33.27 µs ⚪ ~0.15% unchanged
C++ copy(4 KiB) 1.47 µs 1.56 µs 🟡 +6.7% slower (noisy)
C++ large array (1,024) 83.22 µs 83.18 µs ⚪ ~0.05% unchanged
C++ small array (16) 1.69 µs 1.70 µs ⚪ ~0.46% unchanged
C++ synchronousCallback() 504.6 ns 499.5 ns 🟡 -1% faster (noisy)
C++ create() 991.3 ns 1.00 µs 🟡 +1.09% slower (noisy)
C++ returnExisting() 372.8 ns 372.0 ns ⚪ ~0.21% unchanged
C++ typed map (8 entries) 3.39 µs 3.31 µs ⚪ ~2.2% unchanged
C++ optional trailing string 274.5 ns 276.9 ns ⚪ ~0.87% unchanged
C++ addNumbers() 166.6 ns 169.3 ns ⚪ ~1.61% unchanged
C++ simpleFunc() 132.3 ns 133.8 ns ⚪ ~1.16% unchanged
C++ immediatePromise() 1.71 µs 1.72 µs ℹ️ +0.81% slower (advisory)
C++ number property get/set 271.1 ns 279.8 ns ⚪ ~3.2% unchanged
C++ short ASCII string 242.0 ns 244.0 ns ⚪ ~0.84% unchanged
C++ Unicode string 544.8 ns 540.2 ns ⚪ ~0.85% unchanged
C++ nested Car struct 4.40 µs 4.40 µs ⚪ ~0.01% unchanged
C++ number | string variant 261.1 ns 255.9 ns ⚪ ~2.01% unchanged
Kotlin bounce(1 MiB) 1.84 µs 1.81 µs 🟡 -1.73% faster (noisy)
Kotlin bounce(4 KiB) 1.87 µs 1.79 µs 🟡 -4.06% faster (noisy)
Kotlin copy(1 MiB) 460.73 µs 462.70 µs ⚪ ~0.43% unchanged
Kotlin copy(4 KiB) 8.60 µs 8.66 µs ⚪ ~0.77% unchanged
Kotlin large array (1,024) 91.56 µs 91.45 µs ⚪ ~0.12% unchanged
Kotlin small array (16) 2.27 µs 2.25 µs ⚪ ~0.59% unchanged
Kotlin synchronousCallback() 1.72 µs 1.73 µs ⚪ ~0.43% unchanged
Kotlin create() 3.12 µs 3.00 µs 🟡 -3.96% faster (noisy)
Kotlin returnExisting() 1.23 µs 1.23 µs ⚪ ~0.22% unchanged
Kotlin typed map (8 entries) 13.00 µs 13.40 µs ⚪ ~3.08% unchanged
Kotlin optional trailing string 692.5 ns 708.1 ns ⚪ ~2.26% unchanged
Kotlin addNumbers() 269.8 ns 266.5 ns ⚪ ~1.21% unchanged
Kotlin simpleFunc() 207.4 ns 217.3 ns ⚪ ~4.78% unchanged
Kotlin immediatePromise() 4.65 µs 4.63 µs ℹ️ -0.4% faster (advisory)
Kotlin number property get/set 536.5 ns 551.2 ns ⚪ ~2.74% unchanged
Kotlin short ASCII string 920.3 ns 919.9 ns ⚪ ~0.04% unchanged
Kotlin Unicode string 1.48 µs 1.48 µs ⚪ ~0.59% unchanged
Kotlin nested Car struct 9.62 µs 9.60 µs 🟡 -0.17% faster (noisy)
Kotlin number | string variant 868.5 ns 851.7 ns ⚪ ~1.94% unchanged
TurboModule addNumbers() 528.7 ns 533.6 ns ⚪ ~0.93% unchanged

Benchmarking Code Diff fe50d8fc...4fcbe425 (view raw output)

@mrousavy

mrousavy commented Sep 7, 2026

Copy link
Copy Markdown
Member

yea okay that performance bot is not really reliable yet lol

giaBaoJS and others added 2 commits September 7, 2026 13:08
Kotlin only shortens a property's JVM accessor names when `is` is not
followed by a lowercase letter, and it applies that rule to every type,
not just booleans (`JvmAbi.startsWithIsPrefix`). Nitrogen used a plain
`startsWith('is')` check that was additionally gated on the property
being a boolean, so for names such as `isolatedBoolean` or for a
non-boolean `isTextValue` it generated JNI lookups for methods the
Kotlin class does not expose, crashing at runtime on Android.

Also covers the two shapes with `isolatedBoolean` and `isTextValue` on
`SharedTestObjectProps` plus assertions in `getTests.ts`.
@mrousavy
mrousavy force-pushed the fix/kotlin-is-prefix-accessors branch from 4fcbe42 to a9dafb4 Compare September 7, 2026 11:11
@mrousavy
mrousavy merged commit 32b9741 into margelo:main Sep 7, 2026
2 checks passed
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.

2 participants