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

fix(hooks): Return latest decision values on first call, and re-render in response to decision input changes #64

Merged
merged 21 commits into from
Aug 26, 2020

Conversation

mjc1283
Copy link
Contributor

@mjc1283 mjc1283 commented Aug 20, 2020

Summary

This PR addresses two issues with the useExperiment and useFeature hooks:

  • Changes to the overrides argument do not trigger a re-render
  • Even after the client instance is ready, the first call returns false (for feature enabled status from useFeature) or null (for variation assignment from useExperiment).

A boolean-returning isReady method is added to ReactSDKClient. When isReady returns true, useFeature and useExperiment don't wait for the fulfillment of the onReady promise before calling their respective decision methods.

Arguments that affect decision results (experiment/feature key + overrides) are tracked in state, so we can compare prior and current values. When values change, the decision is recomputed. I followed the pattern described here, treating the decision state as being derived from the arguments.

The effect that subscribes to updates is also changed to depend on the decision inputs. Since override user attributes are an object, I used a ref combined with a custom equality function to ensure that the dependency array receives a new object reference only when the contents of the attributes are different.

Test Plan

  • Manually tested using an example React app. Checked useFeature and useExperiment return values both before & after client became ready. Checked that useFeature and useExperiment trigger re-renders when entity key or override arguments change.
  • Unit tests: updated, and added more

@mjc1283 mjc1283 added the wip label Aug 20, 2020
@mjc1283 mjc1283 self-assigned this Aug 20, 2020
Copy link

@mikeproeng37 mikeproeng37 left a comment

Choose a reason for hiding this comment

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

At I high level, this makes sense. I did an initial pass and left some feedback. Will attempt a second, lengthier pass tomorrow.

src/hooks.ts Outdated
* @param {UserAttributes|undefined} newAttrs
* @returns boolean
*/
function areAttributesEqual(oldAttrs: UserAttributes | undefined, newAttrs: UserAttributes | undefined): boolean {

Choose a reason for hiding this comment

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

This feels like it should go in our util/fns.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree, although I don't think it'd be used anywhere else.

src/hooks.ts Outdated
initialDecision: DecisionType,
options: HookOptions = {},
overrides: HookOverrides = {}
): DecisionType {

Choose a reason for hiding this comment

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

This return type doesn't match the docstring

src/hooks.ts Outdated
const { isServerSide } = useContext(OptimizelyContext);
const isClientReady = isServerSide || optimizely.isReady();

const decisionStateAndSetter = useState<DecisionType>(() => {

Choose a reason for hiding this comment

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

Any reason not to destruct this as const [decisionState, setDecisionState] = useState...?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

decisionState has to be declared with let, it may be assigned to below.

src/hooks.ts Outdated
});
}
return (): void => {};
}, [optimizely, entityKey, overrides.overrideUserId, overrideAttrsRef.current]);

Choose a reason for hiding this comment

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

Can we just subscribe to isClientReady instead of the entire optimizely object? Not sure if that will make much of a diff in terms of perf.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If optimizely actually changes, it's a whole new client instance. In that case the effect definitely should rerun. But I think this would be a very rare case - usually it is set once on the provider and never changed.

src/hooks.ts Outdated
});
}, [optimizely, finalReadyTimeout]);

Choose a reason for hiding this comment

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

Same here about subscribing to optimizely

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same answer, that effect needs to rerun if we got a new optimizely.

@mjc1283 mjc1283 removed the wip label Aug 21, 2020
@mjc1283 mjc1283 marked this pull request as ready for review August 21, 2020 23:55
@mjc1283 mjc1283 removed their assignment Aug 22, 2020
Copy link
Contributor

@circAssimilate circAssimilate left a comment

Choose a reason for hiding this comment

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

Hey, @mjc1283, thanks for pinging me about this. Have a few questions on your approach. We could pair on this review and try and knock it out without a lot of back and forth if you prefer. If so, just find some time on my calendar and ping me!

src/hooks.ts Outdated
});
let decisionState = decisionStateAndSetter[0];
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it safe to mutate the state value from useState?

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 sure, I will try removing this.

setDecisionState(getCurrentDecision());
});
}
return (): void => {};
Copy link
Contributor

Choose a reason for hiding this comment

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

Will we have a memory leak issue on unmount? Do we need to remove auto update listeners here?

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 think it's ok because if control reaches this line, no auto update listeners were added.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, rightttt, my misread on this. That's clear to me now.

src/hooks.ts Outdated
const [prevDecisionInputs, setPrevDecisionInputs] = useState<DecisionInputs>(currentDecisionInputs);
if (!areDecisionInputsEqual(prevDecisionInputs, currentDecisionInputs)) {
setPrevDecisionInputs(currentDecisionInputs);
decisionState = getCurrentDecision();
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels like it's unsafe - like it would update the variable but React wouldn't necessarily know. I'm guessing you're doing it to avoid a re-render? Can you point to any docs that talk about this approach? I'm seeing https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops but it doesn't look like anything strange is happening other than a conditional state update which is expected to cause one more render. Doesn't this automatically happen on the next line when setDecisionState() is called?

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 can try taking this out. I just figured since I am updating decision state, I should return the latest value that I'm updating it to. But yea, it's not done that way in the example - even though they call setIsScrollingdown, they don't assign that new value to isScrollingDown before using it.

src/hooks.ts Outdated
if (!areDecisionInputsEqual(prevDecisionInputs, currentDecisionInputs)) {
setPrevDecisionInputs(currentDecisionInputs);
decisionState = getCurrentDecision();
setDecisionState(decisionState);
Copy link
Contributor

Choose a reason for hiding this comment

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

I might be missing something obvious but I can't see what setDecisionState does with decisionState. It looks like a function without any params.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

setDecisionState is a function, it's the 2nd argument returned from useState on line 219. I am trying to update the state value here.

setDecisionState(getCurrentDecision());
});
}
return (): void => {};
Copy link
Contributor

Choose a reason for hiding this comment

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

Same question as below about the need to "unlisten" on unmount.

@mjc1283 mjc1283 requested a review from circAssimilate August 24, 2020 17:49
Comment on lines +250 to +254
const [decisionState, setDecisionState] = useState<FeatureDecisionValues>(() => {
if (isClientReady) {
return getCurrentDecision();
}
return initialState;
return { isEnabled: false, variables: {} };
Copy link
Contributor

Choose a reason for hiding this comment

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

@mjc1283, I am guessing there is something about this implementation of useState that I don’t understand. Can you help me understand what the anon function does here - does it only set initial state or is it used on every setDecisionState. I look at the React docs but couldn't find a clear example. The one I found seemed to insinuate that the provided function would be called every time.

Copy link
Contributor

Choose a reason for hiding this comment

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

@mjc1283 relayed this to me in Slack:

I think the answer is: when you pass a function to useState, it is just called once the first time. It’s like an initializer. It returns the initial value of the state. As described here under “Lazy Initial State”

Copy link
Contributor

@circAssimilate circAssimilate left a comment

Choose a reason for hiding this comment

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

Thanks for the responses and for fixing this, Matt! All looks good to me then if the general functionality is tested and all that. Will we release a minor or patch version for this?

Copy link
Contributor Author

@mjc1283 mjc1283 left a comment

Choose a reason for hiding this comment

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

Thanks for reviewing @circAssimilate . I am thinking this'll be a patch version.

@mjc1283 mjc1283 merged commit b387abb into master Aug 26, 2020
@mjc1283 mjc1283 deleted the mcarroll/issue-58 branch August 26, 2020 17:06
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.

3 participants