Skip to content

Observable and EventData cleanup - #10181

Merged
NathanWalker merged 8 commits into
mainfrom
events-cleanup
Jan 31, 2023
Merged

NathanWalker merged 8 commits into
mainfrom
events-cleanup

Conversation

@shirakaba

@shirakaba shirakaba commented Jan 21, 2023 •

Copy link
Copy Markdown
Contributor

This PR cherry-picks some of the changes done in the course of #10100. It does not implement DOM Events; it just cleans up the existing Observable and EventData source code to reduce the changes needed for DOM Events support (whether implemented into Core or as a separate opt-in package).

PR Checklist

What is the current behavior?

Inconsistent EventData typings, with lots of object?: Partial<Observable>, and thisArg not being passed along and/or correctly typed here and there.

We also have conflicts between observable/index.d.ts and observable/index.ts.

What is the new behavior?

Consistent, simplified, correct typings. One singular observable/index.ts.

Fixes/Implements/Closes #[Issue Number].

No breaking changes anticipated.

@nx-cloud

nx-cloud Bot commented Jan 21, 2023 •

Copy link
Copy Markdown

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 23a3498. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this branch


✅ Successfully ran 2 targets

Sent with 💌 from NxCloud.

@cla-bot cla-bot Bot added the cla: yes label Jan 21, 2023
@shirakaba shirakaba changed the title Events cleanup Observable and EventData cleanup Jan 21, 2023
@shirakaba

shirakaba commented Jan 21, 2023 •

Copy link
Copy Markdown
Contributor Author

I've built locally and didn't expect to see test failures, so I'll dig into those.

EDIT: the test failures seem like they may just be false-negatives regarding sub-pixel rendering in layout tests, so I expect they'll pass on CI even if they don't pass here. Asking advice in the TSC chat.

farfromrefug
farfromrefug previously approved these changes Jan 21, 2023

@farfromrefug farfromrefug left a comment

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.

Looks awesome to me as it is! 2 things i would love to see added:

  • template type for EventData T extending Observable to define "object"
  • adding [k:string]:any to EventData because most EventData are augmented with data props or others.

@shirakaba

shirakaba commented Jan 21, 2023 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @farfromrefug! As for the wishlist:

  • template type for EventData T extending Observable to define "object"

I like it too in theory (have always wanted to just do data.object and get a ready-typed object), though not sure how well it will go - would greatly prefer to tackle that as a followup PR rather than rethink this one.

But in practice, in most contexts (like defining a click handler in React or Svelte), unless it were inline, you'd still need to explicitly specify the generic argument in your callback anyway, so it'd be no less typing saved.

  • adding [k:string]:any to EventData because most EventData are augmented with data props or others.

I see the use - though I think the proper solution for this is for the library-maker to properly type their event handlers (e.g. say that the event named "MyEvent" corresponds to the EventData extension, MyEventData). We really need to get away from loose types and enforce strictness. For now, users can assert as MyEventData where libraries have mistakenly marked the EventData type as just EventData.

@farfromrefug

Copy link
Copy Markdown
Collaborator

About the template type. A simple <T extends Observable = Observable> would help a lot not only for you and your callbacks but also for plugins developer creating custom event types. Plus it is the actual correct typing.

As for the other point I understand what you mean. It is just a pain to have tsc always giving errors about EventData properties while everyone know there is always other properties.

@shirakaba

Copy link
Copy Markdown
Contributor Author

About the template type. A simple would help a lot not only for you and your callbacks but also for plugins developer creating custom event types. Plus it is the actual correct typing.

I can see that that change would touch a lot of files though, so to reduce the review burden, I'll try preparing a draft PR now that depends on this one that implements just that extra step.

@farfromrefug

Copy link
Copy Markdown
Collaborator

@shirakaba it won't change anything! The default T makes it work without a single change !

@shirakaba

shirakaba commented Jan 21, 2023 •

Copy link
Copy Markdown
Contributor Author

@shirakaba it won't change anything! The default T makes it work without a single change !

It certainly wouldn't break anything, but... on, off, addEventListener, removeEventListener, and every interface extending EventData (of which there are many) would need to pass that generic argument around, otherwise there would be no point of use where we'd actually be benefitting from making EventData use a generic.

So I could do just that one-line change, but I don't see what it improves as we'd still have to be casting eventData.object as StackLayout as usual pretty much everywhere, as all the event handlers would still be typed as non-generic unless we followed this rabbit hole and got them all to pass the generic around. I think there's an extra PR's work in there to get it all hooked up.

@farfromrefug

farfromrefug commented Jan 21, 2023 •

Copy link
Copy Markdown
Collaborator

@shirakaba yes it would be best. But as I said I could already use it in extending EventData or in my callbacks. It is already a free and useful gain.
And no would not have to cast to StackLayout if I use the T in my callback signature.
But any way I dont want to force you. Do as you see best

@shirakaba

shirakaba commented Jan 22, 2023 •

Copy link
Copy Markdown
Contributor Author

@farfromrefug I've put together a follow-up commit to implement generic events - as you can see, doing it end-to-end is a big change on top of this PR:

events-cleanup...generic-events

It would potentially be a breaking change for libraries/plugins - any class that were subclassing Observable would now have to add those generic types to the signatures of anything handling event listeners.

It probably wouldn't introduce any breaks for end-users.

But as it may be a breaking change for libraries, let's keep this commit for generics out of this PR and review it in a follow-up PR. Don't want to have to roll back this cleanup PR just due to finding a problem with the generics!

@farfromrefug

Copy link
Copy Markdown
Collaborator

@shirakaba awesome. But I dont think it is a breaking change. You dont need to use generic as all methods / types have default generic.

@shirakaba

shirakaba commented Jan 22, 2023 •

Copy link
Copy Markdown
Contributor Author

The full commit to implement the generic end-to-end would be a compile-time breaking change - playground:

image

Any existing plugins extending Observable (or ViewBase or whatever) and overriding on or addEventListener, etc., would now find that they need to add the generic to the methods.

Just implementing the one-line change export interface EventData<T extends Observable = Observable>, though, would be non-breaking, I agree.

abortedFlags.set(signal, true)
signal.notify({ eventName: "abort", type: "abort" })
abortedFlags.set(signal, true);
signal.notify({ eventName: 'abort', type: 'abort', object: this });

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.

You dont need to pass object:this here. It is added by default from notifiy (if no object set)

@shirakaba shirakaba Jan 23, 2023 •

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.

This PR actually removes the logic in Observable.prototype.notify that fills in an empty object field, so it's necessary (unless we decide to put that logic back in):

- public notify<T extends NotifyData>(data: T): void {
-   const eventData = data as EventData;
-   eventData.object = eventData.object || this;
+ public notify<T extends EventData>(data: T): void { 

Demanding that object be filled in allows us greatly simply the EventData typings. The user gets the same EventData out as they put in. No partials or optionals or anything, and no NotifyData vs. EventData. I think there's a great advantage to simplicity when the codebase has turned off strict type-checking.

In terms of performance as well, making the users pass in the EventData in its final shape rather than us potentially altering EventData, it may go (in some cases, a tiny bit) faster, as V8 won't have to re-assess its Shape upon the reassignment. Although maybe that's only when JIT is enabled...

But I can understand that it's less typing for the user, which is an advantage. I wonder whether this would be an okay middle-ground:

+ // Makes the specified properties in a type optional.
+ type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
+ 
- public notify<T extends NotifyData>(data: T): void {
+ public notify<T extends EventData>(data: Optional<T, 'object'>): void { 
-   const eventData = data as EventData;
    eventData.object = eventData.object || this;

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.

@shirakaba I missed that change but that would be a breaking change though. I know i have many plugins depending on that.

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.

How about the middle ground I suggested, though, which preserves the optional member?

@shirakaba shirakaba Jan 25, 2023 •

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.

I applied a solution in the commit fix: allow "object" to be optional in notify(). Hope you like it!

export const off = global.NativeScriptGlobals.events.off.bind(global.NativeScriptGlobals.events);
export const notify = global.NativeScriptGlobals.events.notify.bind(global.NativeScriptGlobals.events);
export const hasListeners = global.NativeScriptGlobals.events.hasListeners.bind(global.NativeScriptGlobals.events);
export const on = global.NativeScriptGlobals.events.on.bind(global.NativeScriptGlobals.events) as typeof import('.')['on'];

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.

wouldn't it be cleaner to do

import * as IApplication from '.';
... 
export const on = global.NativeScriptGlobals.events.on.bind(global.NativeScriptGlobals.events) as typeof IApplication.on;

I have not "tested" this but i guess it should work

@shirakaba shirakaba Jan 25, 2023 •

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.

Now done in the commit chore: cleaner types using IApplication.

notify<T>(data: any): void;
notifyPropertyChange(propertyName: string, value: any, oldValue?: any): void;
hasListeners(eventName: string): boolean;
[Key in keyof import('data/observable').Observable]: import('data/observable').Observable[Key];

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.

Same as before. Wouldn't it be cleaner to import data/observable as Observable from top and use it here?
maybe it wasn't working?

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.

I'll have to check. I never have much luck defining imports in .d.ts files (have to be very careful not to accidentally turn it into a module, causing it to lose its ambient context). But happy to give that a try.

... Side note, changes to this file are a moot point in the first place as the global types have been broken since @types/node was updated to version 16, as they changed the way you have to augment the global/globalThis context. I have a branch fix/global-types that fixes them that I haven't opened a PR for yet.

@shirakaba shirakaba Jan 25, 2023 •

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.

I tried out a few different approaches in a new minimal project using @types/node@14 (which still uses NodeJS.Global).

❌ Attempt one: importing at top of file

This doesn't work (as expected), as the top-level import changes global-types.d.ts into a module, so it stops filling ambient types.

image

❌ Attempt two: importing within the namespace

This doesn't work either. We'd expect events to bear the eventOne key, but it doesn't auto-complete.

image

⚠️ Attempt three: creating a type alias within the namespace

This does work, but it leaves some pollution - it now means that the users have to put up with NodeJS.Observable being in tsc's compilation context. i.e. they can write const obs = new Observable as NodeJS.Observable.

image

✅ Original attempt

The original suggestion gets working Intellisense without leaving any pollution in the user types.

image

So I think we'd best stick with the code in the PR!


this.notify({
eventName: EditableTextBase.blurEvent,
object: this,

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.

not needed. Added by default and less JS code

@shirakaba shirakaba Jan 25, 2023 •

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.

Now reverted.

@vallemar

vallemar commented Jan 25, 2023 •

Copy link
Copy Markdown
Contributor

a doubt, this will make the type in the event for example: loaded, come defined for the object property?

@shirakaba

shirakaba commented Jan 27, 2023 •

Copy link
Copy Markdown
Contributor Author

This PR

@vallemar This PR will behave as follows:

The callback for the "loaded" event will return an EventData:

export abstract class View extends ViewCommon {
  // ...
  on(event: 'loaded', callback: (args: EventData) => void, thisArg?: any);
  // ...
}

EventData will look like this (note how object is no longer an optional or a Partial<Observable> anywhere):

export interface EventData {
  eventName: string;
  object: Observable;
}

Thus, usage will look like this:

new Button().on("loaded", (eventData: EventData) => {
  const object = eventData.object as Button;
});

So, if you want to access any Button-specific APIs, you will still have to cast the Observable to a Button. But if you only need to call Observable APIs, then no type-casting is necessary.

More than anything, the advantage is that the object is never Partial. And in strict-mode TypeScript, you no longer have to check whether it's undefined.

The end-goal

This PR paves the way for a follow-up PR (this commit: events-cleanup...generic-events ) where we'd introduce generics. That would improve the API to:

new Button().on<Button>("loaded", (eventData) => {
  eventData.object; // TypeScript infers this to be a Button
});

// Or (if I can set up the inference correctly by defaulting to the class itself):
new Button().on("loaded", (eventData) => {
  eventData.object; // TypeScript infers this to be a Button
});

@NathanWalker
NathanWalker merged commit 485fb61 into main Jan 31, 2023
@NathanWalker
NathanWalker deleted the events-cleanup branch January 31, 2023 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants