-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Shuffle the list of available site designs during gutenboarding to prevent bias in user design selection #44979
Shuffle the list of available site designs during gutenboarding to prevent bias in user design selection #44979
Conversation
@@ -74,7 +75,8 @@ export function getAvailableDesigns( | |||
includeAlphaDesigns: boolean = isEnabled( 'gutenboarding/alpha-templates' ), | |||
useFseDesigns: boolean = isEnabled( 'gutenboarding/site-editor' ) | |||
) { | |||
let designs = availableDesigns; | |||
// Randomize the list of designs to prevent bias in user selection | |||
let designs = { ...availableDesigns, featured: shuffle( availableDesigns.featured ) }; |
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.
Just FYI, this is only first draft of reaching the final goal of 1) randomizing the design list 2) persisting the list order during a user session.
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser: App Entrypoints (~160 bytes added 📈 [gzipped])
Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used. Sections (~126 bytes added 📈 [gzipped])
Sections contain code specific for a given set of routes. Is downloaded and parsed only when a particular route is navigated to. Async-loaded Components (~63 bytes added 📈 [gzipped])
React components that are loaded lazily, when a certain part of UI is displayed for the first time. Legend What is parsed and gzip size?Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory. Generated by performance advisor bot at iscalypsofastyet.com. |
Thanks again for the great tips in this issue!! Item 1 🍔I think I'm grasping the functionality of the Gutenboarding Redux store, and that we'll need to add an item in the persist array and create an action and a reducer. (That is the general idea at least.) Then I can just follow the existing dispatch and select patterns in the Gutenboarding code. For example following If all of the above makes sense, then I'm definitely making progress on code comprehension! 🤓 Item 2 🍜In the process of deciphering all of the above I came across a few things that didn't make sense:
Item 3 🍰And a terminology question: |
Great questions @autumnfjeld, thanks for diving in! Item 1Yes, I think actions and reducers are an appropriate way to handle this. It could also make it a good starting point for future follow-ups if we ever wish to replace the hard-coded Another thing to consider is that if we wish to update any of these designs, will their metadata potentially be cached in the store? So, would it be better to persist the sort order of the designs rather than the designs themselves? Or, instead, can we persist the full list of designs, and then at boot check to make sure that none of them have changed (validate that the cache is safe before proceeding). For a concrete example, let's say we decide to remove one of the designs from the backend and from the Just some thoughts! 🙂 Item 2I'm so glad you asked this question because I'd completely forgotten about it! Early on there was the idea that there would be a separate step in the flow where you could select which pages you'd like to add to your site. So, you've already selected your design (how the homepage will look), and then you can select which pages you'd like to add, so you might add an About page and a Contact page. Clicking each of these page layouts would add the string for that layout to the state. So if you added both of these you'd get I think the idea of this feature might still be on the cards at some point, so we might wind up using it eventually. That said, it'd probably be safe for us to take it out in a separate PR at some point if we feel like it'd clean things up by removing it. Item 3
Different things, covered in item 2 ☝️
I think either works, but I quite like Hope that helps, happy to talk through any of the points in more detail! |
|
||
const FontSelect: React.FunctionComponent = () => { | ||
const { __ } = useI18n(); | ||
const { selectedDesign, selectedFonts } = useSelect( ( select ) => | ||
select( STORE_KEY ).getState() | ||
); | ||
const { getRandomizedDesigns } = useSelect( ( select ) => select( STORE_KEY ) ); |
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 idea why line 21 uses a different pattern, with the .getState()
call at the end?
I noticed inconsistency with the store name, here STORE_KEY
is used but in client/landing/gutenboarding/onboarding-block/design-selector/index.tsx ONBOARD_STORE
is used.
And then another difference, the persistent store is called WP_ONBOARD
, though I could understand a motivation for calling the persistent store a different name.
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 might be that historically in Gutenboarding we only had a single store, so referred to it as STORE_KEY
, then when additional stores were added (e.g. PLANS_STORE
) we needed to distinguish between them. In this file it would make sense to use the same import { STORE_KEY as ONBOARD_STORE };
line as we're using elsewhere in Gutenboarding. You're welcome to change it in this PR if you'd like (or leave it as is, whichever you prefer).
I think the WP_
prefix for the persistence is probably going with the pattern borrowed from Gutenberg of using that prefix: https://github.com/wordpress/gutenberg/blob/HEAD/packages/data/src/plugins/persistence/index.js#L38 but I could be wrong!
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.
Coolio, I'll make the change to use import { STORE_KEY as ONBOARD_STORE };
for consistency.
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.
@andrewserong This change has a bit of a ripple effect with respect to typings.
In this font-select.tsx file, I would be replacing
const { selectedDesign, selectedFonts } = useSelect( ( select ) =>
select( STORE_KEY ).getState()
);
with
const { getRandomizedDesigns, getSelectedDesign, getSelectedFonts } = useSelect( ( select ) =>
select( ONBOARD_STORE )
);
and thus replacing all instances of selectedDesign
with getSelectedDesign()
and replacing selectedFonts
with getSelectedFonts()
.
If we look at selectedFonts
in this line
wp-calypso/client/landing/gutenboarding/onboarding-block/style-preview/font-select.tsx
Line 50 in f3a765b
{ getFontTitle( selectedFonts.headings ) } |
when I update that with the selector function
getSelectedFonts()
, typescript complains (see screenshots below) about the potential undefined
values.What I don't understand is that the selector is just returning the reducer value! The
selectedFonts
reducer is typed to return FontPair | undefined
(assuming I'm understanding all this correctly). Do you have an idea about why Typescript was not complaining about a possible undefined state before I made the change to use getSelectedFonts
?
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.
Good question! For the TypeScript problem, it looks like that line is currently covered by a check that selectedFonts
is not undefined (e.g. https://github.com/automattic/wp-calypso/blob/83dbb0fa46689e25608974d5f0768b47a25d8fd3/client/landing/gutenboarding/onboarding-block/style-preview/font-select.tsx#L46), so when the headings
key is accessed, that code path can only run if it's really available. However if you replace that check with getSelectedFonts()
, TypeScript can't know that the next time you call it, the return of that function is definitely going to be the same. From TypeScript's perspective, that function could still potentially return undefined
. In principle this is a good thing, because if the behaviour of the getSelectedFonts
function/selector were to change in the future, we'd need to know this part of the code is still safe.
To keep your changeset small, you could update the name of the constant for the store, but otherwise leave the useSelect call as is, and add your line directly after it. E.g.
const { selectedDesign, selectedFonts } = useSelect( ( select ) =>
select( ONBOARD_STORE ).getState()
);
const { getRandomizedDesigns } = useSelect( ( select ) => select( ONBOARD_STORE ) );
However it also might make sense to group these together. If you do, on the line you're currently looking at, you wouldn't want to call getFontTitle
with an undefined
value, so I think this part of the component might still need to be guarded by the selectedFonts
check. I guess you could add a line before selectedFontOption
— const selectedFonts = getSelectedFonts();
— but either way there's a bit of duplication!
These sorts of little optimisations can sometimes be tricky! My preference is usually to try to change the smallest amount possible that's needed for the change I'm proposing in a PR, and then refactor as necessary in a follow-up janitorial PR. However, sometimes it feels messier to do it that way, so if it makes more sense to change these calls, then by all means, cleaning as you go is good, too!
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 that clarification with regard to const selectedFontOption = selectedFonts ? ( ....
:
it looks like that line is currently covered by a check that selectedFonts is not undefined
Now the typescript issue makes sense. 💡
Now or next :), with a little more awareness under my belt, I did a search for select( ONBOARD_STORE )
and .getState()
and I can see there is a mix of usage of calling .getState()
and using reducers vs not calling .getState()
and using selectors, so I will update the ONBOARD_STORE
name but I will not' make the other changes that replace the reducer with the selectors since both patterns are used throughout gutenboarding.
That was all great exploration to understand reducers and selectors!!! 💡 💡
I'm ready for another WIP review. 🤓 I left comments on specific lines in https://github.com/Automattic/wp-calypso/pull/44979/files. And of course welcome any additional tips, suggestions, ideas for improvements! 👍 I decided to got with persisting the actual randomized array, rather than the sort order. Turned out to be relatively straightforward to compare arrays with a bit of lodash help. I just need to create a test for the I haven't give any thought to tests or checking existing tests for gutenboarding code, so that is what I am going to dive into next. |
Great work so far @autumnfjeld! This approach is looking very neat to me, particularly the clean, well-named functions in I think this is a great case for leaning on lodash where it excels, though, which in this case is for deep object comparison and it wouldn't really make sense for us to roll our own here. You've also abstracted away the lodash calls behind Excellent idea now looking into adding test coverage. I don't think we've got much in the way of test coverage for Gutenboarding yet, which has been brought up a number of times as something we want to address now that the flow is fairly complex. Not sure if it helps, but we've got some minimal tests in the I haven't tested this PR manually yet, but let us know when it's ready, and I can give it a closer look 😀 |
f3a765b
to
7081172
Compare
@andrewserong I'm ready for a final review & manual test of my work. There are three things, as discussed in our PR convos, that I didn't do in the this PR that I will/could do in other PRs:
|
Great work here @autumnfjeld! If I run out of time to give this a detailed test today, I'll take a look first thing on Monday. Good idea re: moving those other tasks to follow up PRs!
If we're feeling confident through manual testing, I think it'll be fine to merge in without a test (the code's looking good to me). However, if you do want to get a test in, we could always move the |
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.
Brilliant work, thank you! The xor
idea is great, wouldn't occur to me at all.
All the comments I gave are 100% optional and more on the nit side. LGTM!
* @param stored randomizedDesigns cached in WP_ONBOARD | ||
* @param available designs sourced from available-designs-config.json | ||
*/ | ||
function isUpToDate( stored: Design[], available: Design[] ): 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.
Slight nit: It seems this function doesn't test for up-to-date-ness, rather equality disregarding order. Maybe call it something less generic like isNominallyEqual
.
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.
Yes, it is checking equality disregarding order, so I see your point. I renamed the function to isDeepEqual. I didn't really understand the usage of 'nominally' here (after I looked it up in the dictionary and I am a native English speaker lol), so I choose deep equal instead. :)
* @param available designs sourced from available-designs-config.json | ||
*/ | ||
function isUpToDate( stored: Design[], available: Design[] ): boolean { | ||
return isEmpty( xorWith( stored, available, isEqual ) ); |
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.
Since we know design slugs are unique, maybe we can map these arrays to slugs to make the comparison faster, i.e compare strings vs traverse and compare objects.
Also, an early length check might save a couple cycles.
return isEmpty( xorWith( stored, available, isEqual ) ); | |
if( stored.length !== available.length ) return false; | |
const storedSlugs = stored.map( ( { slug } ) => slug ); | |
const availableSlugs = available.map( ( { slug } ) => slug ); | |
return isEmpty( xorWith( storedSlugs, availableSlugs ) ); |
If you agree with this, we can get rid of the isEqual
comparator, I'm not 100% sure but I think if you omit it, lodash would use ===
, which is OK for strings.
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 @alshakero! For this function, I think we'll still need to do a deep comparison to protect the data integrity of any of the design objects. My thinking was that if we change anything in available-designs-config.json
we'll want the cache to be invalidated so that someone with persisted state doesn't have incorrect values. E.g. if we swap template, theme, or categories, or accidentally ship a design with a typo in one of these fields, we'll want to make sure that a follow-up deploy will reach users with persisted 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.
oooh totally missed that! Was just thinking about adding or deleting whole designs.
const selectedDesignDefaultFonts = designs.featured.find( | ||
( design ) => design.slug === selectedDesign?.slug | ||
const selectedDesignDefaultFonts = getRandomizedDesigns().featured.find( | ||
( design: Design ) => design.slug === selectedDesign?.slug |
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.
Type is implied by TS since design
comes from getRandomizedDesigns().
( design: Design ) => design.slug === selectedDesign?.slug | |
( design ) => design.slug === selectedDesign?.slug |
…event bias in user design selection
…of randomizedDesigns from call in window.AppBoot when verifying cached designs
…e name in font-select.tsx
… from array, remove Design type
138e1ff
to
b20118b
Compare
Changes proposed in this Pull Request
randomizedDesigns
array in localStorage to maintain the same order of designs during a user's gutenboarding sessionTesting instructions
Fixes
Issue #44874
Follow up
Follow up work noted in comment below #44979 (comment)