Skip to content

Stepper: Automated going back behaviour with non-linear flow support ✨#101550

Merged
alshakero merged 6 commits intotrunkfrom
fix/going-back
Mar 26, 2025
Merged

Stepper: Automated going back behaviour with non-linear flow support ✨#101550
alshakero merged 6 commits intotrunkfrom
fix/going-back

Conversation

@alshakero
Copy link
Member

@alshakero alshakero commented Mar 18, 2025

Fixes #101509

Proposed Changes

Because of my insistence to keep Stepper non-linear, I thought it was impossible to guess the correct going-back behavior on the framework level. And left that puzzle for each flow to piece together. But today I realized we can create a rock-solid default behavior for going back without the flows writing any code.

This is thanks to the previousStep store value that is updated on every Stepper navigation. The existence of this value implies that we're now at least 2 steps deep in the flow at hand, and simply calling history.back() when previousStep is defined, guarantees that 1) we'll go to the previous step 2) we'll remain in the flow.

This implements the most common use case for going back.

This also removes all the goBack logic from the onboarding flow because it's not needed anymore and to allow testing.

Why are these changes being made?

Defining goBack for every flow is too much work and needs maintenance.

Testing Instructions

  1. Go to /setup/onboarding
  2. In the domains step, pick "use a domain I own", go back, should work.
  3. Go back again to use a domain I own, fill the domain, click back, should work.
  4. Click back once more, you should land at the domains step.

@alshakero alshakero requested a review from a team as a code owner March 18, 2025 23:24
@matticbot matticbot added [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. labels Mar 18, 2025
@matticbot
Copy link
Contributor

matticbot commented Mar 18, 2025

This PR modifies the release build for the following Calypso Apps:

For info about this notification, see here: PCYsg-OT6-p2

  • notifications
  • wpcom-block-editor

To test WordPress.com changes, run install-plugin.sh $pluginSlug fix/going-back on your sandbox.

@github-actions
Copy link

github-actions bot commented Mar 18, 2025

@matticbot
Copy link
Contributor

matticbot commented Mar 18, 2025

Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

App Entrypoints (~33 bytes added 📈 [gzipped])

Details
name           parsed_size           gzip_size
entry-stepper       +182 B  (+0.0%)      +33 B  (+0.0%)

Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used.

Sections (~162 bytes removed 📉 [gzipped])

Details
name             parsed_size           gzip_size
onboarding-flow       -844 B  (-0.3%)     -162 B  (-0.2%)

Sections contain code specific for a given set of routes. Is downloaded and parsed only when a particular route is navigated to.

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.
Gzip Size: Compressed size of the JS and CSS files. This much data needs to be downloaded over network.

Generated by performance advisor bot at iscalypsofastyet.com.

Copy link
Member

@scruffian scruffian left a comment

Choose a reason for hiding this comment

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

This is working well for me. I left a few questions but nothing that should block this.

/**
* If the `previousStep` is defined in the store, it's a solid proxy to guess that we navigated at least once via Stepper's React Router.
* If the flow doesn't define a `goBack` handler, and `previousStep` is defined, we can just go history.back() and we'll remain in the flow.
* But if `previousStep` is not defined, and the flow doesn't define a `goBack` handler, we should return undefined so the StepContainer doesn't render a back button.
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible now for a flow to not define a goBack handler, since it will always inherit one?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes exactly. If a flow defines one, it will supersede the default one. So not defining one will give your flow access to the default behavior. This may be shortsighted to say, but I think no flow should need to define its own goBack anymore.

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 worth offering a hybrid option so flows can only override the behaviour in specific situations? That would make it easier for flows to fall through to the default in most cases, and exceptions would not require the flow to implement handling for all possible cases.

Copy link
Member Author

Choose a reason for hiding this comment

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

We can easily check the return value of the goBack from the flow and use that to decide flow.goBack?.() ?? defaultGoBack() but at this point, especially after your comment, I'm convinced we should remove going back from the flow level. It should always be aligned with the browser's back button.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, can this be a side-effect or secondary helper? In my mind, the only thing the flow should be doing is interacting with the flow state, largely in cases where the user might end up leaving the flow.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't disagree, but I'm increasingly dispassionate about increasing the API surface area. Can we wait until we need that?

Copy link
Contributor

Choose a reason for hiding this comment

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

I can think of an edge case that we don't have a good solution for: what happens when you go back to the processing step?

At present, users often get stuck because the original step that triggered an async action is the step before the processing step, and we haven't (re)triggered that original action.

cc @gabrielcaires as he has been thinking about this as a general Stepper issue/gap.

Copy link
Member Author

Choose a reason for hiding this comment

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

Processing and site creation steps should replace history. Like this.

};
}, [] );

const stepData = useSelect(
Copy link
Member

Choose a reason for hiding this comment

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

Is it better to return previous step from the store, rather than all the step data? Just thinking about the performance implications...

Copy link
Member Author

Choose a reason for hiding this comment

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

I tried that, but the whole stepData object can be null, so I had to check it first before accessing previousStep.

handleRecordStepNavigation( {
event: STEPPER_TRACKS_EVENT_STEP_NAV_GO_BACK,
} );
history.back();
Copy link
Member

Choose a reason for hiding this comment

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

I suppose another option could be to actually go to the previous step explicitly like navigate( previousStep ) - not real code! I'm not sure which is better...

Copy link
Member Author

Choose a reason for hiding this comment

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

This was my initial approach, but it's bad because it pushes a new state down the browser's history stack when we want to pop. When I called navigate, going back via the browser went forward 🌀.

@ciampo
Copy link
Contributor

ciampo commented Mar 19, 2025

I'd love to take a deeper look but I may not be able to do it today - hope that' ok!

In the meantime, I have a couple of high-level thoughts:

  • the approach seems smart, although it goes against Stepper's approach of making flow logic as explicit as possible. Just a reflection;
  • what happens if I land on a specific step of a flow directly without having visited the previous steps? I assume goBack won't work in that case?
  • what happens when chaining to a different flow? Can I "go back" to the previous flow?

@alshakero
Copy link
Member Author

alshakero commented Mar 19, 2025

the approach seems smart, although it goes against Stepper's approach of making flow logic as explicit as possible. Just a reflection;

Yes, but this is an unescapable reality because the browser's go back button supersedes whatever Stepper principle we may come up with. We're better off aligning our design with it.

what happens if I land on a specific step of a flow directly without having visited the previous steps? I assume goBack won't work in that case?

That would be an issue that I intentionally ignored. There are a few possibilities

  1. If they land at the step directly, without ever visiting Stepper before, the back button won't show (because previousStep is undefined) ✅
  2. If they're linked to a deep step (why would you make a deliberate link to a deeper step but let's say it happened), they would go back to where they came from (could be a 3rd party site) 👎🏼 bad but not broken.
  3. If they land directly without any history state, say they copy the URL and open it in a new tab, they button will be a noop 👎🏼

I ignored this issue because when we move to sessionIDs and useFlowState, the previousStep will only be persisted for the ongoing session. Meaning the button will only show when it can actually work as intended.

what happens when chaining to a different flow? Can I "go back" to the previous flow?

That's why I added a check to only activate it when you're not at the first step of the flow. Once you cross the first step, you'll have a valid previousStep value.

@daledupreez
Copy link
Contributor

If I think like a user for a moment, I always expect the Back button to work like the browser back button. I assume it will take me back to the last page I was on, whatever that was.

So I would argue that our default behaviour should be something like:

  • If we have a previous step, go to that step (though this will require us to make sure that steps are correctly re-entrant, e.g. the site creation step) -- that's what this PR does
  • If we have a page in the history, go back to that page
  • If we are in a new tab with no history, the Back item/button should be disabled or hidden.

Side note: I have strong feelings about any code that makes the Back button do anything that moves away from this approach!

@ciampo
Copy link
Contributor

ciampo commented Mar 19, 2025

why would you make a deliberate link to a deeper step but let's say it happened

Can you explain what's a "deep" / "deeper" step? I may be missing some context 🙏

I also agree with @daledupreez 's general sentiment against hacking the history back behavior, and It's good to see that this PR is taking us closer to that vision.

"Go back" until now really meant "go to the previous step according to the logic of the flow" , while we are proposing to change it to "go back to the previous browser history entry". Let's make sure that this change can be applied across the board without compromising all the different ways users experience the flows (including direct links to a step, page refreshes, chained flows, etc)

@alshakero
Copy link
Member Author

Can you explain what's a "deep" / "deeper" step? I may be missing some context 🙏

I mean landing directly at a non-first step of a flow.

If I'm being honest, my approach would be to make the back button simply call history.back() in every case and delete all the code. But my gut feeling tells me it's unacceptable for a serious product to redirect the user back to say x.com if they click back inside that product. I have never seen that happen.

So this PR aims to call history.back when 1) it doesn't go to a 3rd party site 2) it will work (in the vast majority of cases).


/**
* If the previous step is defined in the store, and the current step is not the first step, we can go back.
* We need to make sure we're not at the first step because `previousStep` is persisted and can be a step from another flow or another run of the current flow.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can previousStep (and any stale state) be cleanup automatically / expire when starting a new flow?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea. I wouldn't worry about that now though.

Comment on lines -404 to -451
const goBack = () => {
switch ( currentStepSlug ) {
case 'use-my-domain':
if ( getQueryArg( window.location.href, 'step' ) === 'transfer-or-connect' ) {
const destination = addQueryArgs( '/use-my-domain', {
...getQueryArgs( window.location.href ),
step: 'domain-input',
initialQuery: getQueryArg( window.location.href, 'initialQuery' ),
} );
return navigate( destination );
}

if ( window.location.search ) {
window.history.replaceState( {}, document.title, window.location.pathname );
}
return navigate( 'domains' );
case 'plans':
if ( redirectedToUseMyDomain ) {
if ( Object.keys( useMyDomainQueryParams ).length ) {
// restore query params
const useMyDomainURL = addQueryArgs( 'use-my-domain', useMyDomainQueryParams );
return navigate( useMyDomainURL );
}
return navigate( 'use-my-domain' );
}
return navigate( 'domains' );
case 'domains':
if ( isGoalsAtFrontExperiment ) {
if ( isBigSkyBeforePlansExperiment && createWithBigSky ) {
return navigate( 'design-choices' );
}
return navigate( 'design-setup' );
}
case 'design-setup':
if ( isDesignChoicesStepEnabled ) {
return navigate( 'design-choices' );
}
if ( isGoalsAtFrontExperiment ) {
return navigate( 'goals' );
}
case 'difmStartingPoint':
return navigate( 'goals' );
case 'design-choices':
return navigate( 'goals' );
default:
return;
}
};
Copy link
Member

Choose a reason for hiding this comment

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

This is the kind of simplification we love to see! Thanks for seeing it through 🌟

@ciampo
Copy link
Contributor

ciampo commented Mar 24, 2025

  1. If they're linked to a deep step (why would you make a deliberate link to a deeper step but let's say it happened)

Not sure how frequent this is, but I just came across such an example during my latest PR.

they would go back to where they came from (could be a 3rd party site) 👎🏼 bad but not broken.
3. If they land directly without any history state, say they copy the URL and open it in a new tab, they button will be a noop 👎🏼

A few questions:

  • Would going back to where the user came from always a good idea? For example, if the user navigated to the flow as the result of submitting a form, could that result in suboptimal UX?
  • Given that user would leave the flow instead of going to the previous flow step, could this change cause a loss of revenue?

Another thought: if there isn't any history to navigate back to (case 3), should we just automatically hide the back button? And should we also consider hiding it if there isn't any "previous flow step" (case 2)?

All I'm trying to say, is that we need to make sure this change won't cause a worse UX (and potentially, a loss of revenue).

For example, on X, users are still able to click the "back" button and navigate back to a profile even when landing directly on a link (without previous history on the X website) — example.

@alshakero
Copy link
Member Author

Would going back to where the user came from always a good idea? For example, if the user navigated to the flow as the result of submitting a form, could that result in suboptimal UX?

Yes, but the form should handle coming back to it. Stepper handles that, although poorly. If you reach checkout and go back, the site that was created before reaching checkout will be recycled.

Given that user would leave the flow instead of going to the previous flow step, could this change cause a loss of revenue?

It depends on where they came from I think. Really hard to tell.

Another thought: if there isn't any history to navigate back to (case 3), should we just automatically hide the back button?

Great idea. Implemented in 008c5a9 (#101550)

And should we also consider hiding it if there isn't any "previous flow step" (case 2)?

Yes, this PR does that already.

For example, on X, users are still able to click the "back" button and navigate back to a profile even when landing directly on a link without previous history on the X website

Yes, that was my goal when I implemented goBack hook; force the flow to decide the expected going-back path. But everything is a tradeoff and the goBack hook turns out to be a maintenance nightmare.

Copy link
Contributor

@escapemanuele escapemanuele left a comment

Choose a reason for hiding this comment

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

It's so nice to see this happening 👏

@alshakero alshakero merged commit ecdca51 into trunk Mar 26, 2025
13 checks passed
@alshakero alshakero deleted the fix/going-back branch March 26, 2025 16:11
@github-actions github-actions bot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Mar 26, 2025
@alshakero
Copy link
Member Author

Made a revert PR in case things go awry #101907

* If the flow doesn't define a `goBack` handler, and `previousStep` is defined, we can just go history.back() and we'll remain in the flow.
* But if `previousStep` is not defined, and the flow doesn't define a `goBack` handler, we should return undefined so the StepContainer doesn't render a back button.
*/
...( canUserGoBack && {
Copy link
Member

Choose a reason for hiding this comment

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

@alshakero Introducing this synthetic goBack function causes a brief flicker in the logged out onboarding flow when the user transitions from the signup step to the domains step.

Copy link
Member

Choose a reason for hiding this comment

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

I think is a fix. Improves the canUserGoBack logic. #102022

@p-jackson
Copy link
Member

p-jackson commented Mar 28, 2025

The domains step randomly pops up for a second while transitioning from the onboarding to the site-setup flow. This is me unproxied.

CleanShot.2025-03-28.at.15.52.15.mp4

Testing shows that reverting this PR fixes the problem 🙃 I this PR does make changes to the onboarding flow. So I think I've got to do the revert unfortunately.

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.

Task: Stepper - Saner defaults for flow.goBack

8 participants

Comments