From f38e02ebf1ca4cc415a05f12074cfdefd70e49c5 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 1 Feb 2021 19:26:46 -0500 Subject: [PATCH] Support subclassing Observable with non-class constructor functions. Now that the zen-observable-ts package has the ability to export Observable as a native class (#7615), we need to be careful when extending Observable using classes (like ObservableQuery and Concast) that have been compiled to ES5 constructor functions (rather than native classes), because the generated _super.call(this, subscriber) code throws when _super is a native class constructor (#7635). Rather than attempting to change the way the TypeScript compiler transforms super(subscriber) calls, this commit wraps Observable.call and Observable.apply to work as expected, by using Reflect.construct to invoke the superclass constructor correctly, when the Reflect API is available: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/construct Another option would be to ship native class syntax with @apollo/client, by changing the "target" in tsconfig.json from "es5" to "es2015" or later, so that consumers of @apollo/client would be forced to compile native class syntax however they see fit. That would be a more disruptive change, in part because it would prevent subclassing Apollo Client-defined classes using anything other than native class syntax and/or the Reflect.construct API, which is the very same problem this commit is trying to fix for the Observable class. --- src/utilities/observables/Observable.ts | 27 ++++++++ .../observables/__tests__/Observable.ts | 69 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 src/utilities/observables/__tests__/Observable.ts diff --git a/src/utilities/observables/Observable.ts b/src/utilities/observables/Observable.ts index 3d10114c61c..91b0ad31c55 100644 --- a/src/utilities/observables/Observable.ts +++ b/src/utilities/observables/Observable.ts @@ -8,11 +8,38 @@ import { // proposal (https://github.com/zenparsing/es-observable) import 'symbol-observable'; +export type Subscriber = ZenObservable.Subscriber; export type { Observer, ObservableSubscription, }; +Observable.call = function( + this: typeof Observable, + obs: Observable, + sub: ZenObservable.Subscriber, +): Observable { + return construct(this, obs, sub); +}; + +Observable.apply = function( + this: typeof Observable, + obs: Observable, + args: [ZenObservable.Subscriber], +): Observable { + return construct(this, obs, args[0]); +} + +function construct( + Super: typeof Observable, + self: Observable, + subscriber: ZenObservable.Subscriber, +): Observable { + return typeof Reflect === 'object' + ? Reflect.construct(Super, [subscriber], self.constructor) + : Function.prototype.call.call(Super, self, subscriber); +} + // Use global module augmentation to add RxJS interop functionality. By // using this approach (instead of subclassing `Observable` and adding an // ['@@observable']() method), we ensure the exported `Observable` retains all diff --git a/src/utilities/observables/__tests__/Observable.ts b/src/utilities/observables/__tests__/Observable.ts new file mode 100644 index 00000000000..3a58de813d9 --- /dev/null +++ b/src/utilities/observables/__tests__/Observable.ts @@ -0,0 +1,69 @@ +import { Observable, Subscriber } from '../Observable'; + +describe('Observable', () => { + describe('subclassing by non-class constructor functions', () => { + function check(constructor: new (sub: Subscriber) => Observable) { + constructor.prototype = Object.create(Observable.prototype, { + constructor: { + value: constructor, + }, + }); + + const subscriber: Subscriber = observer => { + observer.next(123); + observer.complete(); + }; + + const obs = new constructor(subscriber) as Observable; + + expect(typeof (obs as any).sub).toBe("function"); + expect((obs as any).sub).toBe(subscriber); + + expect(obs).toBeInstanceOf(Observable); + expect(obs).toBeInstanceOf(constructor); + expect(obs.constructor).toBe(constructor); + + return new Promise((resolve, reject) => { + obs.subscribe({ + next: resolve, + error: reject, + }); + }).then(value => { + expect(value).toBe(123); + }); + } + + function newify( + constructor: (sub: Subscriber) => void, + ): new (sub: Subscriber) => Observable { + return constructor as any; + } + + it('simulating super(sub) with Observable.call(this, sub)', () => { + function SubclassWithSuperCall(sub: Subscriber) { + const self = Observable.call(this, sub); + self.sub = sub; + return self; + } + return check(newify(SubclassWithSuperCall)); + }); + + it('simulating super(sub) with Observable.apply(this, arguments)', () => { + function SubclassWithSuperApplyArgs(_sub: Subscriber) { + const self = Observable.apply(this, arguments); + self.sub = _sub; + return self; + } + return check(newify(SubclassWithSuperApplyArgs)); + }); + + it('simulating super(sub) with Observable.apply(this, [sub])', () => { + function SubclassWithSuperApplyArray(...args: [Subscriber]) { + const self = Observable.apply(this, args); + self.sub = args[0]; + return self; + } + return check(newify(SubclassWithSuperApplyArray)); + }); + }); +});