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

Add withSafeTimeout higher-order component #4839

Merged
merged 2 commits into from
Feb 9, 2018
Merged

Conversation

mcsf
Copy link
Contributor

@mcsf mcsf commented Feb 2, 2018

Description

Sparked by recent in situ conversations and by #4603, here's an attempt to make asynchronous function calling safer using a lifecycle-bound setTimeout equivalent.

Depending on use cases, I could also see this accommodating more functions (setInterval, perhaps even debounce), whether via HoC arguments or separate HoCs.

How Has This Been Tested?

  1. Apply this patch to tweak CopyContentButton's behavior.
  2. Go to the editor, open the console.
  3. Open the global ellipsis menu. Click Copy All Content.
  4. Leave the menu open. Make sure that, after a second, the console logs copied.
  5. Open the ellipsis menu again. Click Copy All Content and immediately close the menu.
  6. Make sure that the console doesn't log copied after that.
  7. In editor/hooks/copy-content/index.js, replace the setSafeTimeout( … ) call with the standard window.setTimeout. Repeat step 5 and make sure that the message gets logged even though the menu was previously closed.

Screenshots (jpeg or gifs if applicable):

Types of changes

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code has proper inline documentation.

@mcsf mcsf added [Type] Question Questions about the design or development of the editor. Framework Issues related to broader framework topics, especially as it relates to javascript labels Feb 2, 2018
}

setSafeTimeout( fn, delay ) {
this.timeouts.push( setTimeout( fn, delay ) );
Copy link
Member

Choose a reason for hiding this comment

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

Do you think we should remove finished timeouts? Or should we just let them pile up?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was picturing more short-lived instances, but good point: we should be defensive anyway.

Copy link
Member

@aduth aduth left a comment

Choose a reason for hiding this comment

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

Cool 👍 Agree with @iseulde's point.

Related but not necessary here: Might be worth exploring ESLint rules restricting non-HOC use of timers in components, or even also warning about safe usage (i.e. still expecting them to be used in some cases, but discouraging them as a crutch)

/**
* WordPress dependencies
*/
import { Component, withSafeTimeout } from '@wordpress/components';
Copy link
Member

Choose a reason for hiding this comment

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

Component is unused.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hawk eye!

@mcsf
Copy link
Contributor Author

mcsf commented Feb 2, 2018

Agree on limiting usage of this and guarding against setTimeout. 👍

@ellatrix ellatrix added the [Status] In Progress Tracking issues with work in progress label Feb 5, 2018
@ellatrix
Copy link
Member

ellatrix commented Feb 5, 2018

At first I thought the editor.removed check was still needed in editable, but by then the timeout should be cleared anyway, so this seems useful there too.

Do you think it should be called setSafeTimeout or just setTimeout, if it comes from props and if we may want to expand it?

@mcsf
Copy link
Contributor Author

mcsf commented Feb 5, 2018

Do you think it should be called setSafeTimeout or just setTimeout

Been wondering this too. Pro of setSafeTimeout: the name makes it obvious there's something inherently different about this, and suggests a preferred method. Pro of setTimeout: makes it clear that it can and should be used the same way as window.setTimeout. Second pro: brevity. ;)

Let's try setTimeout unless someone feels strongly otherwise.

if it comes from props and if we may want to expand it?

What do you mean? Expand as in wrap the function, or expand as in accommodate its friends setInterval, etc.?

@ellatrix
Copy link
Member

ellatrix commented Feb 5, 2018

Yeah in this case I'm leaning towards setTimeout. :) And yes, I meant expand with debounce etc.

@aduth
Copy link
Member

aduth commented Feb 5, 2018

One issue I might see is that it introduces variable shadowing with the global setTimeout if one were to destructure, which also has the side-effect of potentially misleading a casual reader into what the setTimeout actually is:

const { setTimeout } = this.props;

// Later...

setTimeout( ... );

The latter point could be perceived as a benefit, since the behavior should be identical anyways to the global, but there is magic involved all the same.

@mcsf
Copy link
Contributor Author

mcsf commented Feb 5, 2018

One issue I might see is that it introduces variable shadowing with the global setTimeout

That's true, though for me it highlights an equivalent but more common problem, which is identifier collision between an action creator and its bound counterpart:

import { doThing } from 'actions'
connect(
  null,
  { doThing }
)( ( { doThing } ) => ( // oops, eslint go sad
  <button onClick={ doThing } />
) )

Gutenberg has, for the most part, avoided this by using its weird action naming convention:

connect( null, { onDoThing: doThing } )(  )

which is a whole other topic and which I personally don't like (in this example, I'd either go for doThing or onClick as bound action creator names), but projects like Calypso are still subject to collision. In Calypso, a pattern I commonly see (and use myself) is to distinguish data props from action props:

render() {
  const { foos } = this.props;
  return <button onClick={ this.props.doThing }>{ foos }</button>;
}

In Gutenberg, where functional components are more common, I could see this emerge:

import { getFoos } from 'selectors'
import { doThing } from 'actions'
connect(
  ( state ) => ( { foos: getFoos( state ) } ),
  { doThing }
)( ( { foos, ...actions } ) => (
  <button onClick={ actions.doThing }>{ foos }</button>
) )

The point of this detour is that I'd welcome a convention that would have us refer to effectful functions with some sort of namespacing (actions.doThing, props.doThing, this.props.doThing…).

@aduth
Copy link
Member

aduth commented Feb 5, 2018

Gutenberg has, for the most part, avoided this by using its weird action naming convention:

While I'd be lying if I said the convention wasn't at all related to the shadowing issue, I don't entirely agree in it being "weird" if considering the implied distinction between presentational and container components. button need not know of doThing being the result of its click — where doThing is more likely something like replaceBlock — since this is the role of the connect mappings to map the presentational behavior of the click (or equivalently named presentational behavior) to the state effects.

I'd agree though that this is often forced and at best still a pain point in props naming.

In Gutenberg, where functional components are more common, I could see this emerge:

One practical concern I might have here can be seen in how this transpiles via Babel:

http://babeljs.io/repl/#?babili=false&browsers=&build=&builtIns=false&code_lz=GYVwdgxgLglg9mABAWQJ4GE4FsAOCCmYUAFIgN6LBxwDOANIgHTMCG08YNiAvogJTluQA&debug=false&forceAllTransforms=false&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&lineWrap=false&presets=latest%2Creact%2Cstage-2&prettier=false&targets=&version=6.26.0&envVersion=

Note the iteration.

Obviously I'd not be fond of letting the specifics of the transpile dictate how code is best written, but it can be a factor, or at least worth noting.

@mcsf
Copy link
Contributor Author

mcsf commented Feb 5, 2018

One practical concern I might have here can be seen in how this transpiles via Babel:

Thanks for checking, it's useful for weighing the discussion.

considering the implied distinction between presentational and container components

I agree with this, and appreciate the decoupling in general. My criticism is targeted at this pattern:

connect( null, { onMultiSelect: multiSelect } )
// later, in the component:
expandSelection(  ) {
  
  this.props.onMultiSelect(  );
}

onMultiSelect is simply the action's name disguised as an event handler name, meaning it is actually coupling event/trigger and action/effect. This is why I initially said I'd have preferred to pick either { multiSelect: multiSelect } or { onSelectionExpanded: multiSelect } (the latter would likely require some custom logic, e.g. { onSelectionExpanded: ( x, y ) => multiSelect( foo( x ), bar( y ) ) }). What I meant by "weird"—and meaning no offense—was this application of "handler case" to an action's name.

return (
<OriginalComponent
{ ...this.props }
setTimeout={ this.setTimeout }
Copy link
Member

Choose a reason for hiding this comment

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

Should we return the timeoutID, in case someone wants to clear the timeout not just on unmount? Should we pass clearTimeout as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should we return the timeoutID

Absolutely, which I just noticed I didn't do.

Pass clearTimeout

This I'm not so sure. What would we provide? The same as window.clearTimeout?

Copy link
Member

Choose a reason for hiding this comment

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

Yes. It might be more convenient to get it from the same place. Without it I'd need to get one from props, and "import" the other from the window. Maybe it's also more future-proof.

Copy link
Member

Choose a reason for hiding this comment

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

Also, I guess to be super correct, it would also need to call this.clear( id ).

@mcsf
Copy link
Contributor Author

mcsf commented Feb 5, 2018

Pushed 4500940 ff410da, which keeps this.timeouts trim by removing IDs as soon as their function calls are due. @iseulde, is this what you had in mind?

The behavior is illustrated by the following screencast, where I intentionally added a couple of seconds to the setTimeout calls (which were using 0 as the delay) to make them visible:

gutenberg-safe-set-timeout

  • Opening and closing the ellipsis menu: the unmount clean-up reveals no pending timeouts
  • Clicking "Copy" and waiting: the timeout is removed as soon as 'copied' is logged
  • Clicking "Copy" several times and waiting: the queued timeouts are successively removed
  • Clicking "Copy" several times and closing the ellipsis menu: a batch of timeouts are removed


clear( id ) {
const newTimeouts = this.timeouts.filter( t => t !== id );
this.timeouts = newTimeouts;
Copy link
Member

Choose a reason for hiding this comment

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

Any reason not to shorten this to the following?

this.timeouts = this.timeouts.filter( t => t !== id );

@ellatrix
Copy link
Member

ellatrix commented Feb 5, 2018

Pushed 4500940, which keeps this.timeouts trim by removing IDs as soon as their function calls are due. @iseulde, is this what you had in mind?

Yes, this is what I did in #4603 as well. :)

I wonder if this PR should also replace (some) setTimeout uses in e.g. the richText component?

@mcsf mcsf force-pushed the add/hoc-with-safe-timeout branch from 4500940 to ff410da Compare February 5, 2018 18:19
@aduth
Copy link
Member

aduth commented Feb 5, 2018

This is why I initially said I'd have preferred to pick either { multiSelect: multiSelect } or { onSelectionExpanded: multiSelect } (the latter would likely require some custom logic, e.g. { onSelectionExpanded: ( x, y ) => multiSelect( foo( x ), bar( y ) ) }). What I meant by "weird"—and meaning no offense—was this application of "handler case" to an action's name.

Oh, no offense taken at all. I'd agree with you, and your example of onSelectionExpanded: multiSelect is indeed what I was getting at. As you point out, and purely on the basis of naming these "correctly", it's not a simple task certainly vs. some predictable pattern.

@mcsf mcsf force-pushed the add/hoc-with-safe-timeout branch from b3edc98 to 360a51d Compare February 5, 2018 18:32
@mcsf
Copy link
Contributor Author

mcsf commented Feb 5, 2018

I wonder if this PR should also replace (some) setTimeout uses in e.g. the richText component?

That sounds good, though I can't do it now (have to step out for the evening). I can take care of it tomorrow, or feel free to add those.

@mcsf
Copy link
Contributor Author

mcsf commented Feb 6, 2018

@iseulde, I've pushed e571c35. Does that match your expectations? If so, is this ready to go?

@ellatrix
Copy link
Member

ellatrix commented Feb 6, 2018

Yes. I wonder if editor.removed is still needed... Theoretically it is only removed on unmount, and then the timer is cleared.

* @param {Function} callback The function to call.
*/
function setSafeTimeout( callback ) {
console.log( 'setSafeTimeout', callback );
Copy link
Member

Choose a reason for hiding this comment

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

Remove console.log ?


clearTimeout( id ) {
clearTimeout( id );
this.clear( id );
Copy link
Member

Choose a reason for hiding this comment

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

Technically I think it'd be fine if the logic of clear was included here, than done separately, since clearTimeout on a finished timeout should be a noop (the reason I'm guessing you separated clear).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, the instinct was the separation, but we can merge.

}

clear( id ) {
this.timeouts = this.timeouts.filter( t => t !== id );
Copy link
Member

Choose a reason for hiding this comment

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

Maybe simpler (and avoiding unlegible abbreviations 😬 ): https://lodash.com/docs/4.17.4#without

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Heh, yes. A consequence of quick prototyping.

@mcsf
Copy link
Contributor Author

mcsf commented Feb 6, 2018 via email

@mcsf mcsf force-pushed the add/hoc-with-safe-timeout branch from e571c35 to 8728a64 Compare February 9, 2018 12:04
@mcsf mcsf removed the [Status] In Progress Tracking issues with work in progress label Feb 9, 2018
@mcsf
Copy link
Contributor Author

mcsf commented Feb 9, 2018

Feedback addressed; final review?

Copy link
Member

@ellatrix ellatrix left a comment

Choose a reason for hiding this comment

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

Wonderful :)

@mcsf mcsf force-pushed the add/hoc-with-safe-timeout branch from 8728a64 to cd735cb Compare February 9, 2018 19:03
@mcsf mcsf merged commit 7916e7a into master Feb 9, 2018
@mcsf mcsf deleted the add/hoc-with-safe-timeout branch February 9, 2018 19:38
@mcsf
Copy link
Contributor Author

mcsf commented Feb 10, 2018

Stumbled upon some logic in embeds, wondering if we should offer a similarly bound version of fetch:

componentWillUnmount() {
// can't abort the fetch promise, so let it know we will unmount
this.unmounting = true;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Framework Issues related to broader framework topics, especially as it relates to javascript [Type] Question Questions about the design or development of the editor.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants