Skip to content

feat: support native ES classes with lazy registration, including static usage before construction#403

Open
NathanWalker wants to merge 2 commits into
mainfrom
feat/native-es-classes
Open

feat: support native ES classes with lazy registration, including static usage before construction#403
NathanWalker wants to merge 2 commits into
mainfrom
feat/native-es-classes

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Makes plain ES2015+ classes that extend native types work directly on iOS, without the @NativeClass decorator or ES5 downleveling:

class JSClass extends NSObject {
  description() {
    return 'hello from ES class';
  }
}

Construction is not the only way a class first crosses into native code. All of the following now trigger lazy registration, before any instance has ever been created:

class JSClass extends NSObject {}

someNativeMethod(JSClass);   // passed as a Class (or id) argument
JSClass.alloc().init();      // alloc before any construction
JSClass.new();               // inherited static factory
JSClass.someStaticMethod();  // inherited static method dispatch
JSClass.someStaticProperty;  // inherited static property get/set

See tests in TestRunner/app/tests/Inheritance/ESClassTests.js.

Summary by CodeRabbit

  • New Features

    • Added support for using plain ES class inheritance with native types.
    • Classes are now registered lazily when first used, including with new and alloc().init().
    • Added improved inheritance behavior for instance methods, static members, protocols, and super calls.
    • Introduced a global NativeClass helper for class decoration and protocol configuration.
  • Bug Fixes

    • Improved handling of class-based values passed into native APIs.
    • Fixed method and property resolution across multi-level class hierarchies.

…tic usage before construction

Plain ES classes extending native types (class JSClass extends NSObject {})
now register their Objective-C subclass lazily on first native use, without
requiring the @nativeclass decorator or ES5 downleveling. Registration is
triggered not only by construction (new/new.target) but by any static touch:
JSClass.alloc().init(), JSClass.new(), inherited static methods and
properties, and passing JSClass directly to native APIs expecting Class or
id arguments. A global no-op NativeClass keeps existing decorated code
working unchanged.
@NathanWalker NathanWalker requested a review from edusperoni July 9, 2026 20:41
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@NathanWalker, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4ce69d31-dea4-442e-95a1-1ed305ba684d

📥 Commits

Reviewing files that changed from the base of the PR and between 431f1f3 and 75a9934.

📒 Files selected for processing (1)
  • TestRunner/app/tests/Inheritance/ESClassTests.js
📝 Walkthrough

Walkthrough

This PR adds support for plain ES class extends NativeBase {} subclasses in the iOS runtime, lazily registering an Objective-C extended class for such constructors. It wires class resolution via new.target into constructor, alloc, and method dispatch paths, adds a wrapper flag, a NativeClass JS helper, and tests.

Changes

ES class native interop support

Layer / File(s) Summary
ClassBuilder and wrapper contracts
NativeScript/runtime/ClassBuilder.h, NativeScript/runtime/DataWrapper.h
Declares EnsureExtendedClass/ResolveConstructedClass, extends ExposeDynamicMethods with visitedNames, and adds an esDerivedClass flag/accessor to ObjCClassWrapper.
EnsureExtendedClass and swizzling core implementation
NativeScript/runtime/ClassBuilder.mm
Implements SwizzleRetainRelease, EnsureExtendedClass (flattens ES class chains into an extended Objective-C class), ResolveConstructedClass, and updates the extended-class constructor callback.
ExposeDynamicMethods visited-name shadowing
NativeScript/runtime/ClassBuilder.mm
Adds visitedNames-based skipping across chain levels, skips "constructor", guards NSFastEnumeration conformance, and switches property enumeration to GetOwnPropertyNames with ALL_PROPERTIES | SKIP_SYMBOLS.
Interop marshalling for ES subclasses
NativeScript/runtime/Interop.mm
Lazily ensures a derived Objective-C class when marshalling functions into Class/id expectations.
MetadataBuilder dispatch resolution for ES subclasses
NativeScript/runtime/MetadataBuilder.mm
Resolves dispatch classes for constructor, alloc, method, and static property access via EnsureExtendedClass/ResolveConstructedClass and a new ResolveStaticReceiverClassName helper.
NativeClass helper and ES class tests
NativeScript/runtime/InlineFunctions.cpp, TestRunner/app/tests/Inheritance/ESClassTests.js, TestRunner/app/tests/index.js
Adds a NativeClass global decorator supporting protocol options, and a new test suite covering lazy registration, inheritance, super calls, protocols, and the decorator.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: edusperoni

Poem

A little rabbit hopped the chain,
from ES class down to ObjC lane,
new.target whispered "which one's true?"
and EnsureExtendedClass knew what to do.
🐰✨ super calls now dance in sync,
tests all green — not a single blink!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: lazy registration support for native ES classes, including pre-construction static usage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment on lines +27 to +31
var object = new ESSimpleObject();
expect(object.constructor).toBe(ESSimpleObject);
expect(object instanceof ESSimpleObject).toBe(true);
expect(object instanceof TNSDerivedInterface).toBe(true);
expect(object instanceof NSObject).toBe(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this call alloc().init()? I'm not sure about the implications of calling new Something() when the native object might have different ideas for initalization.

would ESSimpleObject.alloc().init() call the constructor? what about the constructor arguments?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes in the default case. super() lands in the exact same ArgConverter::ConstructObject path that every NativeScript construction (new NSObject(), legacy .extend() classes) has always used. The only new behavior is that new.target resolution makes it alloc the derived ObjC class instead of the base. The dispatch inside ConstructObject is:

if (result == nil && interfaceMeta != nullptr && info.Length() > 0) {
  std::vector<Local<Value>> args;
  const MethodMeta* initializer =
      ArgConverter::FindInitializer(context, klass, interfaceMeta, info, args);
  result = [klass alloc];

  V8VectorArgs vectorArgs(args);
  result = Interop::CallInitializer(context, initializer, result, klass, vectorArgs);
}

if (result == nil) {
  result = [[klass alloc] init];
}

So for a native class with "different ideas about initialization," the existing constructor-to-initializer matching still applies: super() with no arguments is literally [[DerivedClass alloc] init], while super(args...) runs FindInitializer, which matches the arguments against the class's initWith… selectors from metadata and calls the matched designated initializer. The key detail is that what reaches the native initializer is whatever you pass to super(...), not what's passed to new. The JS constructor body is in control, same as any ES class. If a native base has no usable zero-arg init, the author passes matching args to super(...) or uses the explicit alloc().initWithX(...) pattern, exactly as before.

alloc() triggers lazy registration (creating the ObjC class and installing method/accessor overrides; a metadata operation only) and returns an uninitialized instance; .init() is then an ordinary message send.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed up 2 additional test cases that hopefully clarify that: 75a9934

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