-
Notifications
You must be signed in to change notification settings - Fork 36
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
Conversation
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.
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 { |
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.
This feels like it should go in our util/fns.
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.
Agree, although I don't think it'd be used anywhere else.
src/hooks.ts
Outdated
initialDecision: DecisionType, | ||
options: HookOptions = {}, | ||
overrides: HookOverrides = {} | ||
): DecisionType { |
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.
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>(() => { |
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.
Any reason not to destruct this as const [decisionState, setDecisionState] = useState...
?
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.
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]); |
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.
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.
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 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]); |
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.
Same here about subscribing to optimizely
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.
Same answer, that effect needs to rerun if we got a new optimizely
.
approach for detecting changes in user attributes. Add more tests.
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.
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]; |
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.
Is it safe to mutate the state value from useState?
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.
Not sure, I will try removing this.
setDecisionState(getCurrentDecision()); | ||
}); | ||
} | ||
return (): void => {}; |
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.
Will we have a memory leak issue on unmount? Do we need to remove auto update listeners here?
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 it's ok because if control reaches this line, no auto update listeners were added.
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.
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(); |
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.
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?
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 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); |
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 might be missing something obvious but I can't see what setDecisionState
does with decisionState
. It looks like a function without any params.
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.
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 => {}; |
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.
Same question as below about the need to "unlisten" on unmount.
const [decisionState, setDecisionState] = useState<FeatureDecisionValues>(() => { | ||
if (isClientReady) { | ||
return getCurrentDecision(); | ||
} | ||
return initialState; | ||
return { isEnabled: false, variables: {} }; |
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.
@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.
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.
@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”
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.
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?
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.
Thanks for reviewing @circAssimilate . I am thinking this'll be a patch version.
Summary
This PR addresses two issues with the
useExperiment
anduseFeature
hooks:overrides
argument do not trigger a re-renderfalse
(for feature enabled status fromuseFeature
) ornull
(for variation assignment fromuseExperiment
).A boolean-returning
isReady
method is added toReactSDKClient
. WhenisReady
returns true,useFeature
anduseExperiment
don't wait for the fulfillment of theonReady
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
useFeature
anduseExperiment
return values both before & after client became ready. Checked thatuseFeature
anduseExperiment
trigger re-renders when entity key or override arguments change.