-
Notifications
You must be signed in to change notification settings - Fork 158
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
Attempt to make the useSubscription hook simpler by removing reactivity #305
Conversation
@yched would love your review on this if you have time. |
packages/react-mongo/react-mongo.ts
Outdated
name: string | false, | ||
args: any[] | ||
): void | Meteor.SubscriptionHandle => { | ||
const [subscription, setSubscription] = useState<Meteor.SubscriptionHandle>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should put that in state : we don't need to rerender when the subscription changes
(the fact that the subscription changes will probably cause other rerenders because of minimongo results changing, but that is taken care of outside of here)
A useRef should be enough ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, that's a really good point. We don't need a rerender for a changing subscription. The ready state might change, though. I'll change it to a ref and see if I can get the ready state to invalidate immediately as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the ready state might change, that is handled by the ready() func being a reactive source.
So the consumer can decide to rerender (if he wants) by doing
const isReady = useTracker(ready)
with the ready function we return ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah yes, that means the stub ready() getter we initially return must be reactive too :-/
Well, that might require playing with Tracker deps to build our own reactve func wrapping the one in the subscription handler, rather than handing out that one directly
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I incorporated the changes in 76943f7.
The problem with ready(), is that it will always lag behind one render. So when changing the deps, it will still return the previous subscription's status for one render. I have a suggestion on how to fix that, I'll push that in the next commit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (subscription.current) { | ||
// @ts-ignore | ||
const { deps } = subscription.current | ||
if (deps.name === name && deps.args === args) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you don't need to manually track and compare deps here, you can just set subscription.current back to null in the effect cleanup phase ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, the thing is, this all happens after the render. So it will already be too late by then.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cleanup phase runs after the render, but before the ready() callback you return gets executed I think ?
Then by the time it gets executed, subscription.current has gone back to null and you can just return false ?
(not 100% sure off the top of my head, but worth a try I think)
More importantly, this code still has the same drawback as before : by not running the reactive subscription.current.ready() in some cases, the function we build here won't be reactive later ?
I have something in mind, need to try it first :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cleanup phase runs after the render, but before the ready() callback you return gets executed I think ?
Then by the time it gets executed, subscription.current has gone back to null and you can just return false ?
Nope. Tried 10 different ways, even with useState
, but every time the first ready()
call still references the old subscription.
I tried a bunch of iterations with this API before settling on a Factory method. There were just too many tricky issues to deal with, and I didn't want to start doing things like deep array compares. One unresolvable issue is that if you have an object based publication API, you are basically not going to be able to manage deps effectively this way, without requiring the implementor to jump through hoops to keep their refs immutable. |
@CaptainN there's a couple other ideas in this PR as well, if you have time to take a look (in fact, switching out the Factory method was not even the main point). |
It looks like you did a few different things here. One is that the useSubscriptionClient function no longer returns a SubscriptionHandle, but just a single method (the useCallback method). I'm not apposed to that, but it still. has the problem of inadequate deps checks. |
Right, publications can receive args not just as litterals but also as objects or arrays, and we cant use those directly as hook deps ( |
I completely agree - this API would be nicer. I just can't think of a way to make it work with inline object refs in a clean way. |
In terms of usage, I don't see much difference than using useTracker( () => Meteor.subscribe( 'board', params.id ), [ params.id ] )
// vs
useSubscription( () => Meteor.subscribe( 'board', params.id ), [ params.id ] ) What is the difference, or is it just that it allows for a more future-proof implementation? |
It's precisely intended to allow for more future proof implementation, with much easier maintenance. But if you expand the example, it looks pretty different. const MyComponent = ({ slug }) => {
const sub = useSubscription(() => Meteor.subscribe('board', slug), [slug])
const cursor = useCursor(() => MyCollection.find({ slug }), [slug])
if (sub.ready()) {
return <Loading />
} else {
return <ul>
{cursor.map(thing => <li key={thing.key}>
{thing.text}
</li>}
</ul>
}
} const MyComponent = ({ slug }) => {
const { isReady, data } = useTracker(() => {
const handle = Meteor.subscribe('board', slug), [slug])
const data = MyCollection.find({ slug }).fetch()
return {
isReady: handle.ready(),
data: data
}
}, [slug])
if (isReady) {
return <Loading />
} else {
return <ul>
{data.map(thing => <li key={thing.key}>
{thing.text}
</li>}
</ul>
}
} The first example gets even simpler if you don't use I'm actually running in to familiar problems with the useCursor hook in the implementation though. I'm going to have to look at some solutions like |
@CaptainN I've been toying around with this for the last days, and there is one problem that I keep coming back to:
I think means this hook needs to manage its own state as opposed to relying on However, that's not all bad, as it will also allow an out for this issue:
If we manage our state ourselves, we can use any function we like for comparing const subscription = useSubscription(
'page.content', // subscription name (no factory)
page, // full object passed as argument
( a, b ) => a.id === b.id && a.version === b.version // custom compare function
) Or using an off the shelf compare package: import equal from 'fast-deep-equal'
const subscription = useSubscription('page.content', page, equal) WDYT? |
Thinking a bit more about the much nicer "non-factory" approach and the question of non-litteral args : In this approach where useSubcription() would be a replacement/wrapper for Meteor.subscribe(), it seems totally reasonable that it would rely on EJSON.equals()-based comparisons, just like Meteor.subscribe() does. |
Maybe it's a personal style thing, but I try to avoid leveraging compare options other than those build in to React's core hooks. In case they change their implementation, and to keep dependencies light. That said, we can use |
@yched That's a great find. I wonder if this has positive implications for actually creating these (and other computations) in render... It looks at a glance similar to an approach I was toying with for a bit. |
This seems to work for me : const useSubscription = (name, ...args) => {
const subscription = useRef();
const params = useRef();
const readyDep = useRef(new Tracker.Dependency());
const equalsCommitted = (newName, newArgs) =>
params.current && params.current.name === newName && EJSON.equals(params.current.args, newArgs);
useEffect(() => {
const computation = Tracker.nonreactive(() => (
Tracker.autorun(() => {
subscription.current = name ? Meteor.subscribe(name, ...args) : null;
})
));
if (!equalsCommitted(name, args)) {
readyDep.current.changed();
}
params.current = { name, args };
return () => computation.stop();
}, [name, ...args]);
if (subscription.current && equalsCommitted(name, args)) {
return subscription.current.ready;
}
// Return a fake (but reactive) ready() callback.
return () => {
readyDep.current.depend();
return false;
};
}; Demo code (toggling between no subcription / subscription 1 / subscription 2) function App() {
const [value, setValue] = useState(0);
const ready = useSubscription(value > 0 && 'test_publication', value);
const isReady = useTracker(ready);
return (
<div>
{value > 0 ? `Subscription with ${value}` : 'No subscription'} - {isReady ? 'ready' : 'not ready'}<br/>
<button onClick={() => setValue(prev => (prev + 1) % 3)}>Change</button>
</div>
);
}
// server-side
Meteor.publish('test_publication', function (param) {
Meteor._sleepForMs(1000);
return this.ready();
}); |
This looks good @yched , I tested and it worked for me as well. Moreover, with this line const ready = useTracker( useSubscription( 'board', params.id ) ) Downside of this is of course we still need |
@yched That looks pretty good! I don't think you need to worry about managing deps with I also think we can return a ready method that will enable reactivity, rather than relying on useTracker (and a second computation). We already have reactivity from the computation created in useEffect, so we might as well use that. Your example adjusted: function App() {
const [value, setValue] = useState(0);
const subscription = useSubscription(value > 0 && 'test_publication', value);
const isReady = subscription.ready();
return (
<div>
{value > 0 ? `Subscription with ${value}` : 'No subscription'} - {isReady ? 'ready' : 'not ready'}<br/>
<button onClick={() => setValue(prev => (prev + 1) % 3)}>Change</button>
</div>
);
}
// server-side
Meteor.publish('test_publication', function (param) {
Meteor._sleepForMs(1000);
return this.ready();
}); |
I have a version of the above working, but what I'm finding is that I'm jumping through a lot of hoops to avoid 1 re-render when |
I pushed a version of this to my branch. Check it out and let me know what you think! (I do need to test it - will write tests) |
Tried it out, and it seems to be working well. Already loving this API: // useSubscription( () => Meteor.subscribe( 'boards' ), [] )
useSubscription( 'boards' ) // ✨ And coincidentally I also found this makes my Suspense version a perfect drop-in replacement! Not sure if this was intended, but a nice bonus. import { useSubscription } from '/lib/react-mongo' // No suspense
// import useSubscription from 'meteor-subscribe-suspense' // Suspense
useSubscription( 'board.projects', boardId ) |
This is a concept, work in progress based on @yched's comments here and here.
This changes the
useSubscription
signature to matchMeteor.subscribe
. To be used as follows:Normal:
Conditional:
This branch also contains a fix/workaround for the issue that the previous subscription's status is returned for one render after changing the arguments: e37ba55.