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

Expose useTracker hook and reimplement withTracker HOC on top of it #262

Closed
wants to merge 28 commits into from

Conversation

yched
Copy link
Contributor

@yched yched commented Nov 4, 2018

As initially posted on forums.meteor, this is a first attempt at providing a useTracker hook to get reactive data in functionnal React components.

Example usage :

import { useTracker } from 'Meteor/react-meteor-data';
function MyComponent({ docId }) {
  const doc = useTracker(() => Documents.findOne(docId), [docId]);
  return doc ? <div><h1>{doc.title}</h1>{doc.body}</div> : <Spinner/>
}

The PR also re-implements the existing withTracker HOC on top of it, which:

A couple questions remain open (more details in comments below, to follow soon), but this already seems to work fine in our real-world app heavily based on withTracker.

@apollo-cla
Copy link

@yched: Thank you for submitting a pull request! Before we can merge it, you'll need to sign the Meteor Contributor Agreement here: https://contribute.meteor.com/

@leoc
Copy link

leoc commented Nov 7, 2018

Hi there, just now as I submitted my version of react meteor hooks (#263) I saw yours!

I separated two hooks one for subscription and one for data. I would love to get some feedback on your and mine solution. How do you handle stopping a subscription when a component is unmounted? As far as I understand, the returned function of useEffect does the cleanup. I had to separate the hook functions to have access to the handle in the return function and to return results from find().fetch() / findOne().

Your solution for the first render looks promising. Maybe one could do the same but cache the handles in an array to stop all subscriptions in a useTracker function.

Cheers 👍

@yched
Copy link
Contributor Author

yched commented Nov 7, 2018

@leoc

I separated two hooks one for subscription and one for data

I think offering various focused hooks like useSubscription(name, ...args), useDocument(collection, selector, options), useReactiveVar(var)... could be worthwhile, especially as it encourages splitting into granular reactive functions that only rerun when their dep gets invalidated, rather than a single big one which reruns as a whole when any of its deps gets invalidated.

But as I outlined in the OP, I do see a lot of value in providing a useTracker hook that acts like the current withTracker HOC does, i.e. accepting a function that can mix subscriptions and data : it lets us reimplement withTracker on top of it (as the PR does), which instantly makes all existing Meteor/React apps compatible with React's soon-to-be-released "concurrent mode" (Fiber, Async, Suspense, the name has changed a few times), Otherwise, you have to rewrite your whole app to use "functional components + the new hooks" instead of "classes + withTracker" to be compatible, which would be a huge pain.

Then the more specialized ones (useSubscription, useDocument, useReactiveVar...), are just a couple one-line wrappers around the generic useTracker.

(Also, hooks can't be inside if()s, meaning a hook like useTracker(reactiveFunc) is the only way to be able to conditionally subscribe to a publication - you write the condition in the function. useSubscription(name, ...args) always subscribes)

How do you handle stopping a subscription when a component is unmounted? As far as I understand, the returned function of useEffect does the cleanup.

Yes, stopping the reactive computation on cleanup automatically stops the subscriptions that it made.

Maybe one could do the same but cache the handles in an array to stop all subscriptions in a useTracker function.

Yep, I have written just that actually, didn't find the time to push yet :-) Will try do so soon.

@yched
Copy link
Contributor Author

yched commented Nov 7, 2018

So yes, the challenge here is :

  • We need to run the passed reactiveFn immediately on mount so we can set the result as our initial state and return it to the component for its first render.

  • Yet mount time is too early to trigger side effects, they should only happen in useEffect(), i.e at didMount / didUpdate time (in concurrent mode, React can start mounting a component and then ditch it to render a higher priority update first, and restart mounting our component later)
    So we don't want to actually run subscriptions yet (in the scenario above, we'd have no way to stop the subscriptions made during an aborted mount, and they would just induly persist). Subscriptions should only happen within useEffect at didMount / didUpdate time.

  • The PR so far runs reactiveFn once on mount without subscriptions (by temporarily replacing Meteor.subscribe with a stub), and once right after that onDidMount (with full reactivity and subscriptions).
    Ideally we'd want to run it just once on mount, and then only reactively when one of its deps gets invalidated (or if the dependencies used inside the function change and the )

Last commit solves that by having the Meteor.subscribe stub collect the subscriptions attempted at mount time. We then actually do those subscriptions manually in useEffect (and manually register them to stop when the Tracker.autorun computation is stopped). When the dependencies change, useEffect recreates a new Tracker.autorun computation and no special handling of subscriptions is needed there.

@yched
Copy link
Contributor Author

yched commented Nov 8, 2018

Adjusted the "defer subscriptions from mount time to didMount time" part :

  • Those manual subscriptions at didMount time need to be stopped not when the computation is stopped, but on the first time it's invalidated - since the next rerun will redo the subscriptions "as normal".

  • Current drawback : Meteor usually optimises the "unsubscribe on invalidate, resubscribe to the same subscription on rerun" away, and is smart enough to leave the existing subscription untouched.
    My manual "subscribe on didMount / stop on next invalidate" escapes that, and sends the actual DDP unsubscribe / subscribe to the server.
    --> Need to see if there's a way to trigger Meteor's usual optimisation here.

In short, I guess I need feedback from Tracker / DDP experts here :-)

@yched
Copy link
Contributor Author

yched commented Nov 9, 2018

Well, come to think of it: the thing about "not firing Meteor.subscribe() at mount time because we wouldn't be able to stop the subscriptions if the mount was cancelled / restarted later by React concurrent mode", also applies to Tracker.autorun computations : we wouldn't be able to stop them if the mount is canceled.

A Tracker computation is a kind of subscription (the general notion of subscription, not Meteor.subscribe specifically) : something that you setup at some point and need to stop/cleanup to avoid memory leaks. React concurrent mode says "no subscription at mount time, only at didMoiunt / didUpdate with useEffect()", that applies to Tracker.autorun too.

So I guess the approach before the last two commits was the right one :

  • run the reactiveFn once on mount without autorun and subscriptions to return the initial value,
  • and then once more in useEffect() to setup autorun and subscriptions.

AFAICT, that's also the approach other libraries that do "return the results of a function and update them over time as they change" (like react-redux with its connect(mapStateToProps(state, props)) for instance) are taking : run once on mount, then rerun and setup change tracking on didMount. The difference is that we additionally need to silence Meteor subscriptions made in the first run.

Reverting to 46c243a, then. Simpler code, less awkward messing around with subscriptions...

@jchristman
Copy link

Whoever is in charge of this project - what are your thoughts on this method? I really love the syntax and am wondering if we would be better off spinning of a new package for this so that we can take advantage of it now (as opposed to just doing it as a “polyfill” duke in each project)?

Tl; dr - package maintainers should chime in so we can stop wasting time on this pull request and have someone step up to create a new package.

@jchristman
Copy link

To be clear, I believe this is the right spot for the code to live (with documentation on meteor guide already). Maybe we create a package that can be the standin until hooks are out of RFC stage?

@yched
Copy link
Contributor Author

yched commented Nov 11, 2018

Side note : I'll be away for the next two weeks and won't be working on this in the meantime. As far as I can tell, the current code works fine and looks stable to me.

Still TBD : how exactly the package should deal with "stick with current implementation for React < [the 1st React version that officially ships hooks], use the hook-based implementation otherwise". Maybe just a new major release that requires a minimal React version ?

But yeah, on that question and on the code itself, feedback from the package maintainers is probably what's needed now.

@yched
Copy link
Contributor Author

yched commented Nov 11, 2018

@jchristman :
Yes, ideally this (or something like this) would simply be the next version of react-meteor-data.

As for creating an actual, temporary/experimental package with the code from this PR so that people can actually start using it : why not I guess - at the moment I have no plans of doing that myself, but I'm perfectly fine with someone else taking the code and publishing a package though ;-)

It should IMO just be very clear that it is a temporary package until react-meteor-data provides somithing similar, and that the code hasn't been vetted yet by the authors of the current React integration ?

@dburles
Copy link
Contributor

dburles commented Nov 12, 2018

This looks really awesome! my only worry is createContainer/withTracker backwards compatibility. Are there any strong reasons why we can't leave them as is?

@yched
Copy link
Contributor Author

yched commented Nov 12, 2018

@dburles: see above : the big gain is instantly making all existing apps compatible with React Suspense, by moving away from componentWillMount / componentWillUpdate (fixing #256, #252, #242, #261)

FWIW, our app is pretty withTracker-intensive, and seems to work just fine with the drop-in hook-based reimplementation in this PR.

@jchristman
Copy link

@dburles, there’s also no reason that we couldn’t make a documentation note in README that says React <16 use react-meteor-data@0.2.16?

@dburles
Copy link
Contributor

dburles commented Nov 14, 2018

Okay I agree, I think it's worth it. A note in the readme is also a good idea. @hwillson any thoughts here?

@hwillson
Copy link
Contributor

I like it! We should do some additional testing though, so when this PR is ready to be merged, we'll cut a beta for people to try out. @yched Let us know when you think this PR is ready for review. Thanks!

@yched
Copy link
Contributor Author

yched commented Nov 14, 2018

@hwillson @dburles cool !

I think the PR is ready for review at the moment. You can read the comments above for details about another implementation I tried and abandoned (in short : React Suspense forbids Tracker.autorun / Meteor.subscribe at mount time)

Still TBD : how exactly should the package deal with "stick with current implementation for React < 16.[the 1st version that officially ships hooks], use the hook-based implementation otherwise". Maybe just a new major release that requires a minimal React version ?

@mattblackdev
Copy link

Merge! :)

@menelike
Copy link
Contributor

menelike commented Feb 12, 2019

Imho the benefits of this PR in conjunction with React 16.8 outweigh the urge to stay backward compatible. I just submitted #266 which also relies on a newer React version (16.3). I'm happy to refactor that PR once this has been merged.

Also once generally approved https://github.com/meteor/react-packages/pull/262/files#diff-f54656ab7cd59d21708df27a3c521da5R21 should be solved ;)

});
}

return <Component {...{ ...props, ...data }} />;
Copy link
Contributor

Choose a reason for hiding this comment

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

Just out of curiosity, is there a special reason against {...props} {...data}?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

not really :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed to {...props} {...data}

@yched
Copy link
Contributor Author

yched commented Feb 15, 2019

@dburles @hwillson : any feedback ? React 16.8 has shipped with hooks :-)

@CaptainN
Copy link
Collaborator

I think these changes actually improved my app performance (the updated one, which is probably not visible in email for those following along there). I guess this makes sense, since it's now only setting up a computation once, on render, instead of on render, and then again after render. It is also not rendering twice anymore, which it would have been doing before since a state is set in the withEffect hook, which of course runs after the first render. (Lighthouse seems to confirm this, shaving a second off time to idle in my app).

So we got a more responsive implementation (re-calculates immediately when deps are changed, instead of waiting), and also improved performance!

I think this is still compatible with upcoming Suspense API, but I'm not sure. I wrapped the app in <StrictMode> and got no errors or warnings from that. I also wrapped it in <React.unstable_ConcurrentMode> and it all works beautifully (though, I do get a bunch of warnings from other components like Helmet, Material-UI and React Router - I'm not sure if that's effecting anything)

@menelike
Copy link
Contributor

menelike commented Jun 21, 2019

@CaptainN

I think we're heading into the right direction. Your code results in two render calls on mount (in my example) which I can't observe with the current withTracker implementation.

Today I tried to replicate the current withTracker code (https://github.com/meteor/react-packages/blob/796a075a4bbb36212e6a74c9f3576b119d31c474/packages/react-meteor-data/ReactMeteorData.jsx) without reinventing the wheel:

function useTracker(reactiveFn, deps) {
  const previousDeps = useRef();
  const computation = useRef();
  const trackerData = useRef();

  const [, forceUpdate] = useState();

  const dispose = () => {
    if (computation.current) {
      computation.current.stop();
      computation.current = null;
    }
  };

  // this is called at componentWillMount and componentWillUpdate equally
  // simulates a synchronous useEffect, as a replacement for calculateData()
  // if prevDeps or deps are not set shallowEqualArray always returns false
  if (!shallowEqualArray(previousDeps.current, deps)) {
    dispose();

    // Todo
    // if (Meteor.isServer) {
    //   return component.getMeteorData();
    // }

    // Use Tracker.nonreactive in case we are inside a Tracker Computation.
    // This can happen if someone calls `ReactDOM.render` inside a Computation.
    // In that case, we want to opt out of the normal behavior of nested
    // Computations, where if the outer one is invalidated or stopped,
    // it stops the inner one.
    computation.current = Tracker.nonreactive(() => (
      Tracker.autorun((c) => {
        if (c.firstRun) {
          // Todo do we need a try finally block?
          const data = reactiveFn();
          Meteor.isDevelopment && checkCursor(data);

          // don't recreate the computation if no deps have changed
          previousDeps.current = deps;
          trackerData.current = data;
        } else {
          // make sure that shallowEqualArray returns false
          previousDeps.current = Math.random();
          // Stop this computation instead of using the re-run.
          // We use a brand-new autorun for each call to getMeteorData
          // to capture dependencies on any reactive data sources that
          // are accessed.  The reason we can't use a single autorun
          // for the lifetime of the component is that Tracker only
          // re-runs autoruns at flush time, while we need to be able to
          // re-call getMeteorData synchronously whenever we want, e.g.
          // from componentWillUpdate.
          c.stop();
          // trigger a re-render
          // Calling forceUpdate() triggers componentWillUpdate which
          // recalculates getMeteorData() and re-renders the component.
          forceUpdate(Math.random());
        }
      })
    ));
  }

  // replaces this._meteorDataManager.dispose(); on componentWillUnmount
  useEffect(() => dispose, []);

  return trackerData.current;
}

I only added shallowEqualArray in order to stay consistent with the effect API.

In withTracker I added props as Object.values() as it enhances the shallow comparison.
const data = useTracker(() => getMeteorData(props) || {}, props ? Object.values(props) : props);

Update

Maybe it's even better to pass no deps at all in conjunction with withTracker so that a re-render always triggers a recomputation. memo wraps useTracker anyway and it's much closer to the current implementation.


From my observations (this stuff is hard to debug) this code works 100% the same as withTracker now (also tested in my large meteor project).

After all I think that the current code from this PR introduces some performance issues as you've mentioned earlier (it's like managing state only with componentDidUpdate while getDerivedStateFromProps is needed). Those should be gone now.

What do you think?

@menelike
Copy link
Contributor

menelike commented Jun 21, 2019

Note: One thing I still need to determine is if useEffect uses Object.is() and/or shallow comparison.

Update I found it

https://github.com/facebook/react/blob/master/packages/shared/objectIs.js

I don't understand all reasons behind reacts implementation (for example, why continue in the for loop and no early exit!?)
https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberHooks.js#L307-L354

I'd propose:

function is(x, y) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

function shallowEqualArray(nextDeps, prevDeps) {
  if (!prevDeps || !nextDeps) {
    return false;
  }

  const len = nextDeps.length;

  if (prevDeps.length !== len) {
    return false;
  }

  for (let i = 0; i < len; i++) {
    if (!is(nextDeps[i], prevDeps[i])) {
      return false;
    }
  }

  return true;
}

@menelike
Copy link
Contributor

@yched @CaptainN

I just submitted my proposal to yched#1

@CaptainN
Copy link
Collaborator

CaptainN commented Jun 21, 2019

Oh right! I added a useState at the last minute to preserve a reference to computation for cleanup - but of course setting that triggers a second update. Actually, setting state as a backup the way I do in useStateWithDeps probably also triggers a re-render, so replacing that with useRef also makes sense. (I agree, this stuff is hard to debug/test. We should probably have unit tests.)

I'd like to re-invent the wheel a little bit. The original withTracker implementation throws away the computation and rebuilds it for every single props change. This makes sense, because they where limited to the information provided in props, and there's no way to know what effect those changes would have on the computation. In useTracker we are providing a way to say precisely what values will have an impact, so it makes sense to avoid recalculation if we have that data. We only need to create a new Tracker instance when the deps change. But we should also have a backup plan in cases where the user has not supplied deps - in that case, it can work like the old withTracker and always rebuild the computation. In this way, useTracker would never be worse than the old withTracker, but if we supply deps, it can be much better! All upside.

Here's what I'm thinking (very close to your last implementation):

function is(x, y) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

function shallowEqualArray(nextDeps, prevDeps) {
  if (!prevDeps || !nextDeps) {
    return false;
  }

  const len = nextDeps.length;

  if (prevDeps.length !== len) {
    return false;
  }

  for (let i = 0; i < len; i++) {
    if (!is(nextDeps[i], prevDeps[i])) {
      return false;
    }
  }

  return true;
}

function useTracker(reactiveFn, deps) {
  const computation = useRef();
  const previousDeps = useRef();
  const trackerData = useRef();

  // if prevDeps or deps are not set or falsy shallowEqualArray always returns false
  if (!shallowEqualArray(previousDeps.current, deps)) {
    previousDeps.current = deps;

    // Stop the previous computation
    if (computation.current) {
      computation.current.stop();
    }

    // Note : we always run the reactiveFn in Tracker.nonreactive in case
    // we are already inside a Tracker Computation. This can happen if someone calls
    // `ReactDOM.render` inside a Computation. In that case, we want to opt out
    // of the normal behavior of nested Computations, where if the outer one is
    // invalidated or stopped, it stops the inner one too.
    computation.current = Tracker.nonreactive(() => (
      Tracker.autorun((c) => {
        trackerData.current = reactiveFn();
        if (Meteor.isDevelopment) checkCursor(trackerData.current);
      })
    ));
  }

  // Stop the last computation on unmount
  useEffect(() => () => computation.current.stop(), []);

  return trackerData.current;
}

And then withTracker just needs a small update to remove usage of deps:

export const withTracker = options => Component => {
  const expandedOptions = typeof options === 'function' ? { getMeteorData: options } : options
  const { getMeteorData, pure = true } = expandedOptions

  function WithTracker(props) {
    const data = useTracker(() => getMeteorData(props) || {})
    return data ? <Component {...props} {...data} /> : null
  }

  return pure ? memo(WithTracker) : WithTracker
}

This should get us the benefits of the hooks lifecycle (or whatever it's called) while retaining the old behavior for withTracker.

@menelike
Copy link
Contributor

menelike commented Jun 21, 2019

I agree that this small piece of code should be as performant as it can get, it will be used several times in projects and might make the difference between a fast and slow meteor/react app.

I must miss something but I don't see any crucial changes to your example code, but I agree that useTrackershould have a dependency check and should minimize the need for wasted recomputations. But this is how I actually think that I implemented it?

In short:

  • In conjunction with withTrackerevery re-render recreates the computation. A PureComponent is used to block unnecessary calls.
  • useTracker gives you the freedom to pass deps, if you don't, the computation will be recreated on every render, if you do it checks for (shallow)equality and recreates only if a change is detected.

So the only place where a recomputation is wasted is when a reactive value changes over time and after the first run of the computation (!computation.firstRun). All my attempts to get rid of this failed, and I am not really sure if there is any performance gain to expect (after all, if deps change we MUST recreate the computation anyway?).

@CaptainN
Copy link
Collaborator

Actually, I'm questioning why this is here:

        } else {
          // make sure that shallowEqualArray returns false
          previousDeps.current = Math.random();
          // Stop this computation instead of using the re-run.
          // We use a brand-new autorun for each call to getMeteorData
          // to capture dependencies on any reactive data sources that
          // are accessed.  The reason we can't use a single autorun
          // for the lifetime of the component is that Tracker only
          // re-runs autoruns at flush time, while we need to be able to
          // re-call getMeteorData synchronously whenever we want, e.g.
          // from componentWillUpdate.
          c.stop();
          // trigger a re-render
          // Calling forceUpdate() triggers componentWillUpdate which
          // recalculates getMeteorData() and re-renders the component.
          forceUpdate(Math.random());
        }

It says something about "re-runs" but what does that actually mean? Is it not enough to use the memoized value from the last reactive run? Why does it spend an entire additional render cycle each time the computation is re-built (which in the case of withTracker is every time any prop changes or the component mounts)?

@menelike Do you know why it does this? It seems like we can get rid of that entire extra cycle, no?

@menelike
Copy link
Contributor

@CaptainN Yes I agree, this is exactly the part I wanted to get rid of in the past but failed. I faced async related issues. My idea was to call the reactive function, save it to ref.current and force an update.

On the other hand I also think that re-creating a computation is not a heavy task, the most impact is on Meteor.subscribe of which arguments are checked per EJSON.equals(sub.params, params). So it should reuse the Subscriptions, and if it doesn't, this means that some deps have changed (which means the computation needs to be recreated anyway).
I'll give it a second shot over the next days, but I don't expect a success.

The comment and logic you mentioned is actually taken from https://github.com/meteor/react-packages/blob/devel/packages/react-meteor-data/ReactMeteorData.jsx#L68-L79

@CaptainN
Copy link
Collaborator

CaptainN commented Jun 21, 2019

Oh! I got it. That's actually the part that causes a re-render when the tracker data changes from outside the render method (duh). My solution would not work, given that. Using the old code is perfect (now that I understand it.)

Then here is some feedback for your PR - there is a duplicated effort - you don't need this, because it's already been handled at the bottom of the js file in the export statement.

  // When rendering on the server, we don't want to use the Tracker.
  // We only do the first rendering on the server so we can get the data right away
  if (Meteor.isServer) {
    return reactiveFn();
  }

I'd probably use an incrementing value instead of a random value for force update - it's entirely possible, if improbable, that you get the same value from two separate Math.random() invocations.

Other than that, it looks golden! Nice job.

@menelike
Copy link
Contributor

menelike commented Jun 21, 2019

Then here is some feedback for your PR - there is a duplicated effort - you don't need this, because it's already been handled at the bottom of the js file in the export statement.

fixed by yched@365584f

I'd probably use an incrementing value instead of a random value for force update - it's entirely possible, if improbable, that you get the same value from two separate Math.random() invocations.

fixed by yched@c3803b9
fixed by yched@ddfb7cd
This wasn't needed at all since null will always resolve into a re-render, it was an old piece of code I missed removing.

Other than that, it looks golden! Nice job.

Thank you very much and thanks a lot for this very productive collaboration. Actually this also helped me understand react hooks much better.

@CaptainN
Copy link
Collaborator

Yeah, this was fun - I learned a ton about Hooks, and even some of the upcoming stuff like Suspense, and StrictMode, ConcurrentMode (which makes my app SO SMOOTH). Super great learning time, and I think it's a real improvement to the product.

@CaptainN
Copy link
Collaborator

@menelike After sitting with this for a while, and doing some performance testing in my app, there is still one thing I don't quite understand. Why do we need to run the computation synchronously for every single change - what does this comment mean?

// We use a brand-new autorun for each call
// to capture dependencies on any reactive data sources that
// are accessed.  The reason we can't use a single autorun
// for the lifetime of the component is that Tracker only
// re-runs autoruns at flush time, while we need to be able to
// re-call the reactive function synchronously whenever we want, e.g.
// from next render.

It would seem we should only have to re-build the autorun when the deps change, not every time, but I don't really understand what this comment means when it talks about re-runs and "flush time". I get why it should run synchronously for the first run, but why do we need it to run synchronously for every additional render?

In my app, my computation is slow compared with my react render - almost 4 times slower (this probably indicates a problem with my queries, but still). It would be great to allow that to run asynchronously most of the time.

@CaptainN
Copy link
Collaborator

CaptainN commented Jul 1, 2019

Actually, I think I was reading that wrong. When I tried to verify that, being more careful to start from empty offline storage, etc., the graph looked a bit different. Reading it correctly showed me an error outside this component, which is pretty useful. I don't think my queries are that slow, but I did run a bunch more tests, and I can at least confirm that the synchronous version of useTracker is faster than the asynchronous (useEffect based) version - in my specific case, it's an average of around 30-50% faster. Pretty sweet!

I would still like to know why its important to run the reactive function synchronously, as described in that comment.

@CaptainN
Copy link
Collaborator

CaptainN commented Jul 1, 2019

I have a couple of other thoughts, we might as well figure out before any of this ships (I guess it will at some point).

  1. It'd be nice to have a cleanup method in the interface - something users can use to clean up something, like stopping a subscription or clearing out a session var. I added this, along with a couple of examples in this PR.

  2. In many cases, I wonder whether the react version of deps compare (shallow direct compare) is enough. This question applies both @yched's and @menelike's branches. In this example:

const myQueryFromProps =  { _id: props.myDocumentId, public: true }
const myData = useTracker(() => {
  const handle = Meteor.subscribe('myPublication', myQueryFromProps)
  return myCollection.find(myQueryFromProps).fetch()
}, [myQueryFromProps])

myQueryFromProps will be a new object every render. This makes the deps check kind of useless. I suppose this is standard "hooks" behavior, since a useEffect hook would have the same problem. Maybe that means it's not worth our time to change - but it might be worth documenting. The way around rerendering is to be careful about what you put in deps, or to use a ref to hold a single instance of the object and always modify that:

const myQueryFromProps = useRef({ _id: null, public: true })
myQueryFromProps.current._id = props.myDocumentId
const myData = useTracker(() => {
  const handle = Meteor.subscribe('myPublication', myQueryFromProps.current)
  return myCollection.find(myQueryFromProps.current).fetch()
}, Object.values(myQueryFromProps.current))

I guess this is more of a React thing than a Meteor thing, but is it reasonable to ask a Meteor user to do all this? In the React space, there is a react eslint "exhaustive-deps" package which would explicitly prohibit this kind of optimization, because they say it would lead to bugs. But I can't think of how you'd achieve the same level of of efficiency without it. There is a lengthy issue on this.

(This example is contrived - if a user creates that myQueryFromProps object inside the useTracker handler, instead of outside, it would also solve the problem. But what if a user grabs the object from elsewhere, and passes it in to useTracker? Its something worth knowing about.)

I think the good news on this is, I don't think this is any less efficient than the old withTracker, while it does at least offer a way to optimize if you want to.

Copy link

@boomfly boomfly left a comment

Choose a reason for hiding this comment

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

I was check this variant and it's working!!!
This implementation #263 not fully working and i spent many hours to find why sometimes subscription after handle.ready() calls automatically handle.stop()

@CaptainN
Copy link
Collaborator

CaptainN commented Jul 8, 2019

@boomfly Would you mind testing out the PR here? It's a fork of this PR with some additional work to make the initial render synchronous. The result is more immediate availability from the reactive function and 1 fewer re-renders (the current version here always renders at least twice).

The PR @menelike spearheaded actually does a few nice things. If makes deps optional, and if you leave them out then useTracker performs essentially the same as withTracker has, with every reactive invocation running synchronously during render, and a new computation created with every change (this was standard for withTracker). If you add deps then the initial reactive function run (c.firstRun) still runs synchronously, but additional reactive runs will run outside of the react render cycle, asynchronously (like @yched's approach), and will re-use the computation instead of tearing down and rebuilding every time.

It's the best of both worlds!

@CaptainN
Copy link
Collaborator

How do we get this (and this) merged and deployed?

@CaptainN
Copy link
Collaborator

I just produced this neat package for maintaining state which survives HCP on top of useTracker - I had to include the hook implementation in my project to make it work. Will this ever get merged?

I also figured out how to do unit tests for react hooks in there. Maybe we should add some of those for useTracker.

@hwillson
Copy link
Contributor

There has been a lot of conversation around this PR, so just to confirm - is this fully ready for review?

@CaptainN
Copy link
Collaborator

CaptainN commented Jul 17, 2019

@hwillson The PR we have over on @yched's fork (spearheaded by @menelike) is ready for review. Would it be simpler to create a new PR directly from that fork?

That fork solves two important issues with the current PR:

  1. It only renders once, instead of twice like the current implementation in this PR does for every mount. It runs the firstRun synchronously with React render, and only causes rerender when the reactive func's reactive stuff changes, or deps change. For reactive updates after firstRun, with deps the reactive func runs asynchronously, without deps it runs synchronously.
  2. It corrects the response to deps changes to work as you would expect. Each time the deps change, it stops the computation immediately, then on the next render, it rebuilds the computation, and runs that firstRun synchronously.
  3. As a bonus, it also makes deps optional - and if you don't specify them, then the hook works almost exactly like withTracker has worked, in that it will always discard and rebuild the computation, and every invocation runs synchronously with render. This gives a perfectly backward compatible behavior to withTracker.

@hwillson
Copy link
Contributor

@CaptainN Creating a new PR from that fork would be great - thanks!

@yched
Copy link
Contributor Author

yched commented Aug 21, 2019

This is now superceded by #273, I should have closed this a while ago.

@yched yched closed this Aug 21, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet