Repository navigation
RFC: Native ES classes — constructors that always run, interop.initializer, and real Objective-C hierarchies
#56
edusperoni
started this conversation in
1. Ideas & Discussions
Replies: 1 comment 2 replies
|
|
2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
RFC: Native ES classes — constructors that always run,
interop.initializer, and real Objective-C hierarchiesfeat/es-classes-construction— ios#403's lazy registration (@NathanWalker) rebased onmain, then the Objective-C hierarchy and the construction contractSummary
NativeScript lets you subclass native types. ios#403 made that work with plain ES classes —
class MyView extends UIView {}with no decorator and no ES5 downleveling, registered with the Objective-C runtime lazily on first use. That makes extending native classes cheap: unlike.extend()and@NativeClass, which register every subclass when its module loads, a class costs nothing native until it's actually used. This RFC builds on that and proposes the contract for how those classes are constructed, which NativeScript/ios#403 left open:new MyView(),MyView.alloc().initWithFrame(f), a storyboard, cell reuse,NSCoding, or an Objective-C factory. Class fields and#privatemembers always exist.[interop.initializer.args]picks the native initializer, and[interop.initializer.native]is present when Objective-C started the construction. Theinterop.Args<T>type makes "your keys may be missing because Objective-C created this" a type-checked fact, not a convention.class B extends A extends UIViewgives[b isKindOfClass:A] == YES, and an ES class may extend a legacy.extend()class.Android is pending; the API is designed to carry over (see Android).
Motivation
Extending a native class should cost nothing until it's used. Today both ways of subclassing register the Objective-C class when the class is defined:
.extend()allocates and registers it on the call, and the TypeScript__extendspath (what@NativeClassdownlevels to) does the same as soon as the class body evaluates. In practice that's module load, i.e. app startup. Every delegate, data source and view subclass an app or@nativescript/coredefines pays forobjc_allocateClassPair/objc_registerClassPairup front, whether or not that screen is ever opened, and a registered class can never be unregistered. Plain ES classes register lazily instead: definingclass X extends UIView {}touches nothing native, the class is registered the first time it's constructed, allocated, called statically, or handed to Objective-C, and a class that's never used never reaches the Objective-C runtime. Code that needs the class to exist up front under its own name (storyboards,NSClassFromString) opts in withNativeClass({ name, eager: true }). Making these classes the first-class way to extend native types — instead of an ES5-downleveled special case — is only worth it if their construction is as reliable as the legacy paths, which is what the rest of this RFC is about.Two different objects depending on who created them. Objects Objective-C creates itself (
alloc]/init, cell reuse, storyboards,NSCoding) reached JS as a bare object with the class prototype grafted on. They never ran[[Construct]], so class fields wereundefined,#privateaccess threw, and constructor logic was skipped.new MyView()andMyView.alloc().init()produced objects that looked the same and behaved differently.Constructor arguments that mean two things.
new NSFoo(a, b)picks a native initializer by matching JS value shapes against initializer signatures —initWithInt:andinitWithDouble:are indistinguishable, andnullmatches anything. In a subclass, the same positional arguments also had to carry the class's own data. The generated.d.tsalready only types the token form (constructor(o: { frame: CGRect })).The static-factory workaround. Because constructors don't reliably run,
@nativescript/coreis full of:We want
new UITableViewDelegateImpl({ owner })to just work.A flattened hierarchy. NativeScript/ios#403 registered
B extends A extends UIViewas two sibling Objective-C classes underUIView, copyingA's methods intoB.b instanceof Awas true in JS and[b isKindOfClass:A]false in Objective-C — breakingUIAppearance, class-keyed registration and KVO.Guide-level explanation
1. The constructor always runs
In every case, the order is the same as for any ES class: native initializer → class fields → constructor body.
super(...)is where the native initializer runs, sothisexists (and is fully initialized natively) after it, exactly like plain JS.2. One options object, with honest types
interop.Args<T>is a union of the two ways a constructor can be reached:Call sites stay flat and keep required keys required:
new Delegate({ owner, list })compiles,new Delegate({ owner })does not.Inside the constructor you must pick a path before reading your data —
args.ownerwithout narrowing is a type error:Union shapes work:
interop.Args<{ owner: Owner; style: number } | { owner: Owner; nib: string }>narrows within(or akindtag, which also makes TypeScript reject a literal mixing both variants' keys).Classes with no data of their own just write
constructor(args: interop.Args = {}), or no constructor at all.3. Choosing the native initializer
The initializer is named by its constructor tokens — the same keys the generated
.d.tsuses — under[interop.initializer.args]:[interop.initializer.args]→init.{ reuseIdentifier, style }and{ style, reuseIdentifier }both pickinitWithStyle:reuseIdentifier:. Order only breaks a tie between two initializers taking the same set.NSError**as a token:{ parameter1, parameter2 }runsinitWithParameter1:parameter2:error:.{ empty: undefined }runsinitWithEmpty(the generated.d.tsalready types it{ empty: void }).{ frame }as user data is just data.4. When Objective-C starts the construction
When Objective-C sends an initializer (
[[Cell alloc] initWithStyle:… reuseIdentifier:…], a storyboard'sinitWithCoder:,MyView.alloc().initWithFrame(f)from JS), the runtime calls the constructor with:super(args)then runs that initializer with Objective-C's original arguments, exactly once. The prefilled tokens object is frozen: to change an argument, pass a new object ({ ...args, [interop.initializer.args]: { frame: inset(frame) } }), which runs the initializer with the JS values instead. Because the runtime prefills[interop.initializer.args], spread order decides who wins, on both paths:The same spread order gives JS subclasses of JS subclasses the usual override semantics: a parent that defaults lets its subclass choose; a parent that forces doesn't.
5. Initializers the runtime never sees: construction on first use
The runtime hooks every initializer the metadata lists for the native base class. Some objects are created through initializers the metadata doesn't list — typically a public class factory that runs a private initializer:
Such an object is constructed the first time it reaches JS — as a return value, an argument, or
thisin the first overridden method Objective-C calls on it. The constructor receives{ [interop.initializer.native]: { selector: null, args: [] } }and no[interop.initializer.args]: the object is already initialized, sosuper(args)binds it as is and no initializer runs.Two consequences worth knowing:
6. Subclassing ES classes — and legacy classes
Objective-C dispatch reaches each JS level, and every
super.foo()lands one level up. An ES class may also extend a legacy.extend()class (previously refused); the legacy class'sthis.superkeeps working. Extending an.extend()class with.extend()is still not supported.7. Errors
super(7),super(a, b),super(someClassInstance)super({ frame })(the old token spelling){ [interop.initializer.args]: { frame } }— it would otherwise silently runinit{ [interop.initializer.args]: { frme } }init()/initWithFrame()method in an ES class[interop.initializer.native]to an unrelatedsuper(...)errorevent /__onUncaughtError); the initializer returnsnil. Thrown normally fornew.NSExceptionErrorwith the exception'sname/reasonandnativeException, like any other native callReference-level details
Registration.
class X extends Native {}registers lazily (first construction,alloc, static call, or when passed where aClass/idis expected), or eagerly viaNativeClass({ name, eager })(NativeScript/ios#403). A JS-defined parent is registered first and becomes the superclass. Only the class's own prototype level is exposed; inherited members come through Objective-C inheritance.Initializer hooks. On the first ES class above a native (or legacy) base, the runtime installs a hook for every initializer the metadata lists that the base actually implements (optional protocol initializers the base lacks are skipped, so
respondsToSelector:doesn't change; variadic ones are skipped). ES subclasses inherit the hooks. A hook either:new X(), or a nested initializer likeinit→initWithFrame:), orsuper(...)produced.super(...). Accepts nothing,undefined, or one plain object (object literal or spread; a class instance is rejected). It reads only the two symbol keys. On the native path it claims the pending construction only if the class matches and any[interop.initializer.native]value is the one the runtime created — so a nestednew Other()evaluated in the constructor's arguments can't steal it. (A constructor that constructs its own class before callingsuper()is the one case that can; the inner instance claims the native object and the outersuper()fails with a clear error.)Dynamic subclasses. An instance whose runtime class is a dynamic subclass of an ES class (KVO's
NSKVONotifying_…) is constructed as that ES class.Binding before init.
thisis bound to the native object before the initializer runs, on both paths, so overrides the initializer calls (setFrame:,traitCollectionDidChange:…) see the instance under construction. As in any JS class whose base constructor calls an overridden method, fields aren't initialized yet at that point. If the initializer returns a different object,thisis rebound; if it returns an already-wrapped object (a singleton), that object becomes the result ofsuper(...).Ownership. The JS instance holds its own reference; the Objective-C caller keeps the one the initializer returned. On a failing construction started by Objective-C, the caller's allocation is released (as a failing initializer does) and
nilreturned.super.method()reaching a native method dispatches to the superclass of the rootmost JS-defined class in the receiver's chain, not the receiver's immediate superclass — every JS-defined class in between would call back into JS and recurse.Symbols.
interop.initializer.args/.nativecome from V8's API symbol registry: one pair per isolate, shared by its contexts, and not reachable throughSymbol.for.Workers. Unchanged from NativeScript/ios#403: classes are isolate-scoped, eager registration no-ops on workers.
TypeScript
UITableViewControllerInit = { style } | { coder } | { nibName; bundle }) and aconstructor(args: interop.Args<{}, XInit>)overload, so[interop.initializer.args]is checked against the base class's real initializers (a misspelled token is a compile error).@NativeClasscan enforce the contract with a conditional-type check: the object the runtime passes on the native path must be assignable to the constructor's parameter. That acceptsArgs<…>, no-argument and implicit constructors, and rejects(owner: Owner),(a: { owner: Owner }), and a JS-only(a: T & {…})that forgot the native case. Undecorated classes rely on the runtime checks.{ owner, something, somethingElse }is accepted byArgs<A | B>unless the variants carry a tag.Drawbacks
nil, rather than surfacing at a JS call site.init*()overrides are rejected in ES classes. Code using the earlier workaround (init() { const self = super.init(); … }) must move to the constructor. Legacy.extend()classes are unaffected.Alternatives considered
super's arguments. Its single pending slot was consumed by any construct, sosuper({ url: new NSURL(…) })bound theNSURLwrapper to the wrong object. Kept here only as the fallback for initializers the runtime can't hook.init()as the one shared hook. Doesn't give class fields or#privateto Objective-C-created instances.super(), creator picks the initializer (X.alloc().initWithFrame(f)only). Forces JS data back into static factories — the pattern this RFC wants to retire.super({ frame })). Ambiguous, and collides with the class's own data.Partial<T>options. Lets{ owner }compile whensomethingis required for every JS caller.{ data: T }. Works, but every call site gets noisier andargs.data?.ownernever acknowledges the native path.constructor(init: interop.NativeInit, owner?: Owner)). Enforceable, but every call site becomesnew X(interop.init(), owner).Android
The Android runtime has the same problem in a different shape: the framework instantiates
Activity,Fragment,View(from XML),Service… reflectively, through specific Java constructors, and a JSconstructoreither never runs or runs without its data. The proposal for Android is to keep the same JS-facing contract — one options object,interop.Args<T>,[interop.initializer.native]present when the platform created the object (with the Java constructor's signature and arguments),[interop.initializer.args]choosing thesuperconstructor — so a class's constructor reads the same on both platforms. Mapping details (static binding generator, constructor signatures as tokens) are pending and will be added here.Adoption plan
interoptypings and the generator's per-class initializer unions.static initWithOwner(...)factories to constructors once both platforms ship.Unresolved questions
interop.initializer.args/interop.initializer.native— better spellings?interopis typed in@nativescript/types-ios,NativeClassin@nativescript/core'sglobal-types.d.ts, and the per-class unions come from the runtime's metadata generator.super({ frame })currently throws (always). Throw only in debug builds?X.prototype.foo = …after registration) still don't reach Objective-C.All reactions