Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hooks: Use own instance's doAction for built-in hooks #26498

Merged
merged 3 commits into from
Nov 23, 2020

Conversation

yuliyan
Copy link
Contributor

@yuliyan yuliyan commented Oct 27, 2020

Description

Hooks are currently using doAction from the shared global instance to trigger the built-in hooks hookAdded and hookRemoved. This PR binds the methods for adding and removing hooks to their own hooks instance so that they use doAction from the given instance for the said built-in hooks.

How has this been tested?

Tests pass successfully.

Types of changes

Bug fix: Use own instance's doAction method to trigger built-in hookAdded and hookRemoved hooks.

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.

@gziolo gziolo added the [Package] Hooks /packages/hooks label Oct 27, 2020
@gziolo gziolo added the [Type] Bug An existing feature does not function as intended label Nov 6, 2020
@gziolo
Copy link
Member

gziolo commented Nov 6, 2020

@adamsilverstein - would you mind checking this bug report?

const hooks = Object.create( null );

Object.assign( hooks, {
addAction: createAddHook( actions ).bind( hooks ),
Copy link
Member

Choose a reason for hiding this comment

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

The actions and filters objects should be properties of the parent hook object, rather than being related together just by closure variable references.

This also avoids having to call bind() on the return value of every create* function. The bind call will still be there, but hidden inside the create* implementation.

addAction: createAddHook( hooks, 'actions' );

etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you think it'd be better update it here, or separately in a follow-up PR?

Copy link
Member

Choose a reason for hiding this comment

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

@yuliyan I think making this update will also help fix the type checking errors reported by @adamsilverstein ('this' implicitly has type 'any'). So you probably want to do it right away 🙂

@adamsilverstein
Copy link
Member

Hi @jsnajdr and thanks for the bug report. I have some questions:

Is this a bug or more of an enhancement? eg. before your change are you saying the doAction calls for hook Added/Removed are at the global level? So if I created a private hooks instance, those events were still leaking out/firing on the global wp.hooks instance? If I remove your createHooks.js changes, do your added tests fail?

My only other question is about performance, looking at the travis run, it looks fine/unchanged or better.

@adamsilverstein
Copy link
Member

@jsnajdr I see some errors when trying to build the PR:
packages/hooks/src/createRemoveHook.js:78:4 - error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.
packages/hooks/src/createAddHook.js:93:4 - error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.

Can you fix those issues in your branch?

@yuliyan
Copy link
Contributor Author

yuliyan commented Nov 11, 2020

Hi @adamsilverstein,

Is this a bug or more of an enhancement? eg. before your change are you saying the doAction calls for hook Added/Removed are at the global level? So if I created a private hooks instance, those events were still leaking out/firing on the global wp.hooks instance?

I consider this more of a bug, and there are a few reasons to see it like so:

  • Both createAddHook and createRemoveHook depend on the shared global instance's doAction, which results in private instances also being dependent on the global instance.
  • Global instance is no different than any other privately created instance. Having that in mind and given the previous statement, global instance creates an indirect circular dependency relation (e.g. index.js → createHooks.js → createAddHook.js → index.js ).

If I remove your createHooks.js changes, do your added tests fail?

Yes, added tests would fail, as these are covering whether private hooks instances would dispatch hookAdded and hookRemoved within the same instance.


My only other question is about performance, looking at the travis run, it looks fine/unchanged or better.

There should be little to no affect on the performance, since the shared global instance and its methods are no different than a private instance.


@jsnajdr I see some errors when trying to build the PR:

I'm unable to reproduce these errors by running npm run build:packages locally to build the PR. Am I missing something?

@jsnajdr
Copy link
Member

jsnajdr commented Nov 11, 2020

Is this a bug or more of an enhancement?

I agree with @yuliyan that this fixes a bug. The hooks package exposes a method to create your own hooks "registry", and also creates and exposes a default one, as a singleton. All the instances should be completely independent from each other. That's a pattern that's also used by data or i18n. Provide factory to create instances, and also provide a default instance as singleton.

@jsnajdr
Copy link
Member

jsnajdr commented Nov 11, 2020

I'm unable to reproduce these errors by running npm run build:packages locally to build the PR.

@yuliyan build:packages transpiles the sources with Babel, without typechecking. Can you try npm run build:package-types? That runs the TypeScript compiler to create type definitions, and should detect and report any type errors.

@yuliyan
Copy link
Contributor Author

yuliyan commented Nov 11, 2020

I'm unable to reproduce these errors by running npm run build:packages locally to build the PR.

@yuliyan build:packages transpiles the sources with Babel, without typechecking. Can you try npm run build:package-types? That runs the TypeScript compiler to create type definitions, and should detect and report any type errors.

@jsnajdr, doesn't npm run build:packages run npm run build:package-types as well?

Anyway, I tried with npm run build:package-types and I don't see any errors either.

@jsnajdr
Copy link
Member

jsnajdr commented Nov 12, 2020

@yuliyan OK then the problem is something else: it was only in #26430, merged on Nov 3, when the hooks package was configured to have typechecking and to emit type declarations. Your PR is based on an earlier master commit somewhere in late October. If you rebase onto latest master, you will see the errors about the type of this that @adamsilverstein reports. I was able to reproduce them by rebasing and running npm run build:packages.

@yuliyan
Copy link
Contributor Author

yuliyan commented Nov 13, 2020

@adamsilverstein, I've reworked the hooks referencing in create* functions as suggested above by @jsnajdr, which also fixed the type errors.

@jsnajdr, could you also take a look and let me know if you spot anything that can be improved?

@jsnajdr
Copy link
Member

jsnajdr commented Nov 18, 2020

The type of the object returned by createHooks(), i.e., the primary API of the package, was much better defined before this patch than it is now 🙁

Before, the createHooks() return value was an interface with methods like addFilter, and the two "stores" called actions and filters. addFilter had a type of AddHook, specifying exactly the parameters and return value.

After the patch, the object is just a generic bag of functions (Record<string, Function>). And the type of addFilter is just Function.

@jsnajdr
Copy link
Member

jsnajdr commented Nov 19, 2020

I tried to type the createHooks() function and its return value correctly, and it's challenging 🙂

The "hook registry" instance, returned by createHooks and its type called Hooks, has to have two methods: addFilter and onAdd. The addFilter method needs to be self-bound, so that it can be passed around as a value and called like addFilter( ... ), not like hooks.addFilter( ... ). The method impl calls onAdd, so it needs to have access to the Hooks object. It's created by a "method factory" called createAddHook that needs hooks: Hooks as parameter, so that the methods can call onAdd.

That makes the Hooks instance construction self-referential: while constructing the object, I'm calling functions that take the Hooks instance as a parameter.

The only way I found to make TypeScript happy is to declare a public interface and then define a class that implements it:

type HookType = "filters" | "actions";
type Hook = boolean;

export interface Hooks {
  filters: Record<string, Hook>;
  actions: Record<string, Hook>;
  addFilter(name: string): void;
  addAction(name: string): void;
  onAdd(type: HookType, name: string): void;
}

class HooksImpl implements Hooks {
  // have to repeat `Hooks` field declarations, otherwise the `HooksImpl` type
  // is deemed "incomplete".
  filters: Hooks["filters"];
  actions: Hooks["actions"];
  addFilter: Hooks["addFilter"];
  addAction: Hooks["addAction"];
  onAdd: Hooks["onAdd"];

  constructor() {
    this.actions = Object.create(null);
    this.addFilter = createAddHook(this, "filters");
    this.addAction = createAddHook(this, "actions");
    this.onAdd = (type: HookType, name: string) => {
      console.log("Added hook:", type, name);
    };
  }
}

export function createHooks(): Hooks {
  return new HooksImpl();
}

function createAddHook(hooks: Hooks, type: HookType) {
  return (name: string) => {
    hooks[type][name] = true;
    hooks.onAdd(type, name);
  };
}

One notable attempt that doesn't work:

function createHooks(): Hooks {
  const hooks = {};
  hooks.addFilter = createAddHook( hooks, "filters" );
  /* ... */
  return hooks;
}

It fails with this error:

error TS2345: Argument of type '{}' is not assignable to parameter of type 'Hooks'.
  Type '{}' is missing the following properties from type 'Hooks': filters, actions, addFilter, addAction, onAdd

It's a Catch 22: I can't pass hooks to createAddHook until it fully implements the Hooks type, which means having the addFilter property. But addFilter property will be there only after successful call to createAddHook!

If the solution with HooksImpl implements Hooks can be rewritten into JSDoc type annotations, it solves the problem. Cc @sirreal who might contribute other ideas or feedback.

@jsnajdr
Copy link
Member

jsnajdr commented Nov 19, 2020

A simpler implementation that doesn't use interface and implementing class, but just a class:

class HooksClass {
  filters: Record<string, Hook>;
  actions: Record<string, Hook>;
  addFilter: (name: string) => void;
  addAction: (name: string) => void;
  onAdd: (type: HookType, name: string) => void;

  constructor() {
    this.actions = Object.create(null);
    this.addFilter = createAddHook(this, "filters");
    this.addAction = createAddHook(this, "actions");
    this.onAdd = (type: HookType, name: string) => {
      console.log("Added hook:", type, name);
    };
  }
}

export type Hooks = InstanceType<typeof HooksClass>;

export function createHooks(): Hooks {
  return new HooksClass();
}

The HooksClass class is private to the module and not exported as part of public API, i.e., consumer can't create instances directly. But the type is public, exported in index.d.ts as the Hooks type alias. That means the consumer code can declare variables of Hooks type.

@yuliyan
Copy link
Contributor Author

yuliyan commented Nov 19, 2020

@jsnajdr, I'm trying to wrap my head around your suggestion and I'm unsure if HookClass properties are supposed to get their type definitions from the return type of the factory function they are initialized with in the constructor, but when I tested it, it seems like they remain with their initial type, e.g.:

const hooks = createHooks();

hooks.addAction  // (name: string) => void

I'm currently experimenting a different approach to manually define the hooks instance using the callback definitions, i.e.:

/**
 * @typedef Hooks
 * @property {AddHook} addAction
 * ...
 * @property {Store} actions
 */

@jsnajdr
Copy link
Member

jsnajdr commented Nov 19, 2020

I'm trying to wrap my head around your suggestion and I'm unsure if HookClass properties are supposed to get their type definitions from the return type of the factory function they are initialized with in the constructor

I think there is a big difference in how TypeScript treats classes and other objects. If Hooks is a class, then the types of the properties must be declared explicitly (are not inferred from the constructor) and the assignments need to match the type.

On the other hand, the createHooks in master returns one big object:

function createHooks() {
  return {
    addFilter: createAddHook( filters ),
    addAction: createAddHook( actions ),
  }
}

and then the return type of createHooks is completely inferred.

But that no longer works once we want to pass the Hooks object to createAddHook(). This doesn't work because hooks is used before fully defined:

function createHooks() {
  const hooks = {
    addFilter: createAddHook( hooks, filters ),
    addAction: createAddHook( hooks, actions ),
  };
  return hooks;
}

And this doesn't work because when the object is constructed incrementally, TypeScript complains that the value passed to createAddHook is incomplete (misses some properties) and doesn't match the Hooks type:

function createHooks() {
  const hooks = {};
  hooks.addFilter = createAddHook( hooks, filters );
  hooks.addAction = createAddHook( hooks, actions );
  return hooks;
}

Using a class is the only way out of this as far as I know. When the "incremental construction" happens in a class constructor, TypeScript is happy with that:

constructor() {
  this.addFilter = createAddHook( this, filters );
  this.addAction = createAddHook( this, actions );
}

@sirreal
Copy link
Member

sirreal commented Nov 19, 2020

I like @jsnajdr's suggestion, a class nicely represents the Hooks object we want - it encapsulates related data and methods. We just expose a createHooks() factory from the package 👍

I went ahead and implemented the suggestion with JSDoc types, here's the diff:

class implementation
diff --git a/packages/hooks/src/createHooks.js b/packages/hooks/src/createHooks.js
index 27fe8b3824..361383a3a9 100644
--- a/packages/hooks/src/createHooks.js
+++ b/packages/hooks/src/createHooks.js
@@ -9,42 +9,51 @@ import createCurrentHook from './createCurrentHook';
 import createDoingHook from './createDoingHook';
 import createDidHook from './createDidHook';
 
+/**
+ * Internal class for constructing hooks. Use `createHooks()` function
+ *
+ * Note, it is necessary to expose this class to make its type public.
+ *
+ * @private
+ */
+export class _Hooks {
+	constructor() {
+		/** @type {import('.').Store} actions */
+		this.actions = Object.create( null );
+		this.actions.__current = [];
+
+		/** @type {import('.').Store} filters */
+		this.filters = Object.create( null );
+		this.filters.__current = [];
+
+		this.addAction = createAddHook( this, 'actions' );
+		this.addFilter = createAddHook( this, 'filters' );
+		this.removeAction = createRemoveHook( this, 'actions' );
+		this.removeFilter = createRemoveHook( this, 'filters' );
+		this.hasAction = createHasHook( this, 'actions' );
+		this.hasFilter = createHasHook( this, 'filters' );
+		this.removeAllActions = createRemoveHook( this, 'actions', true );
+		this.removeAllFilters = createRemoveHook( this, 'filters', true );
+		this.doAction = createRunHook( this, 'actions' );
+		this.applyFilters = createRunHook( this, 'filters', true );
+		this.currentAction = createCurrentHook( this, 'actions' );
+		this.currentFilter = createCurrentHook( this, 'filters' );
+		this.doingAction = createDoingHook( this, 'actions' );
+		this.doingFilter = createDoingHook( this, 'filters' );
+		this.didAction = createDidHook( this, 'actions' );
+		this.didFilter = createDidHook( this, 'filters' );
+	}
+}
+
+/** @typedef {_Hooks} Hooks */
+
 /**
  * Returns an instance of the hooks object.
+ *
+ * @return {Hooks} A Hooks instance.
  */
 function createHooks() {
-	/** @type {import('.').Store} */
-	const actions = Object.create( null );
-	/** @type {import('.').Store} */
-	const filters = Object.create( null );
-	actions.__current = [];
-	filters.__current = [];
-
-	/** @type {import('.').Hooks} */
-	const hooks = Object.create( null );
-
-	Object.assign( hooks, {
-		addAction: createAddHook( hooks, 'actions' ),
-		addFilter: createAddHook( hooks, 'filters' ),
-		removeAction: createRemoveHook( hooks, 'actions' ),
-		removeFilter: createRemoveHook( hooks, 'filters' ),
-		hasAction: createHasHook( hooks, 'actions' ),
-		hasFilter: createHasHook( hooks, 'filters' ),
-		removeAllActions: createRemoveHook( hooks, 'actions', true ),
-		removeAllFilters: createRemoveHook( hooks, 'filters', true ),
-		doAction: createRunHook( hooks, 'actions' ),
-		applyFilters: createRunHook( hooks, 'filters', true ),
-		currentAction: createCurrentHook( hooks, 'actions' ),
-		currentFilter: createCurrentHook( hooks, 'filters' ),
-		doingAction: createDoingHook( hooks, 'actions' ),
-		doingFilter: createDoingHook( hooks, 'filters' ),
-		didAction: createDidHook( hooks, 'actions' ),
-		didFilter: createDidHook( hooks, 'filters' ),
-		actions,
-		filters,
-	} );
-
-	return hooks;
+	return new _Hooks();
 }
 
 export default createHooks;
diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js
index ada041d3d1..bf6a6a9834 100644
--- a/packages/hooks/src/index.js
+++ b/packages/hooks/src/index.js
@@ -26,14 +26,8 @@ import createHooks from './createHooks';
 
 /**
  * @typedef {Record<string, Hook> & {__current: Current[]}} Store
- */
-
-/**
  * @typedef {'actions' | 'filters'} StoreKey
- */
-
-/**
- * @typedef {Record<string, Function> & Record<StoreKey, Store>} Hooks
+ * @typedef {import('./createHooks').Hooks} Hooks
  */
 
 const {

@yuliyan
Copy link
Contributor Author

yuliyan commented Nov 19, 2020

@jsnajdr @sirreal, thank you both for the help! I've updated the PR with the suggested changes.

@jsnajdr
Copy link
Member

jsnajdr commented Nov 20, 2020

The patched hooks package works great for me, including the types and autocomplete in editor:

Screenshot 2020-11-20 at 10 30 57

But there is one change in the types that could be considered as breaking: before this patch, the Hooks type was used for the "hooks registry" in createHooks().actions or createHooks().filters. The type of createHooks() was completely inferred and didn't have a name.

After this patch, the return type of createHooks() is Hooks, i.e., the original name has a different meaning now, and what was Hooks before is now Store. You can see that in the screenshot above, in the autocomplete for the actions property.

@sirreal
Copy link
Member

sirreal commented Nov 20, 2020

But there is one change in the types that could be considered as breaking: before this patch, the Hooks type was used for the "hooks registry" in createHooks().actions or createHooks().filters. The type of createHooks() was completely inferred and didn't have a name.

After this patch, the return type of createHooks() is Hooks, i.e., the original name has a different meaning now, and what was Hooks before is now Store. You can see that in the screenshot above, in the autocomplete for the actions property.

As far as I can tell, the typed hook package hasn't been released yet so this is an extension of the original enhancement of adding types. The latest release does not appear to include build-types: https://unpkg.com/browse/@wordpress/hooks@2.10.0/

@sirreal sirreal added the npm Packages Related to npm packages label Nov 20, 2020
Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

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

I'm happy with where this is at. Thanks @yuliyan!

@jsnajdr
Copy link
Member

jsnajdr commented Nov 20, 2020

As far as I can tell, the typed hook package hasn't been released yet so this is an extension of the original enhancement of adding types.

Perfect! That makes the PR ready to ship in my view 👍

@sirreal sirreal merged commit c59cbd1 into WordPress:master Nov 23, 2020
@github-actions
Copy link

Congratulations on your first merged pull request, @yuliyan! We'd like to credit you for your contribution in the post announcing the next WordPress release, but we can't find a WordPress.org profile associated with your GitHub account. When you have a moment, visit the following URL and click "link your GitHub account" under "GitHub Username" to link your accounts:

https://profiles.wordpress.org/me/profile/edit/

And if you don't have a WordPress.org account, you can create one on this page:

https://login.wordpress.org/register

Kudos!

@github-actions github-actions bot added this to the Gutenberg 9.5 milestone Nov 23, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
npm Packages Related to npm packages [Package] Hooks /packages/hooks [Type] Bug An existing feature does not function as intended
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants