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

feat: create new browser history entry on some user actions #128

Merged
merged 7 commits into from
Aug 29, 2024

Conversation

ifrost
Copy link
Contributor

@ifrost ifrost commented Aug 23, 2024

✨ Description

Related issue(s):

Fixes #121

πŸ“– Summary of the changes

Adds basic support for browser history. A new history is created upon user actions:

  • Changing exploration type
  • Changing profile type
  • Changing service name
  • Changing group by in labels exploration

Here's how it works:

browser-history.mp4

Technical details

The approach is to make sure we add new user actions to the history stack. Just before the user action handler is called we create a new empty entry in the stack and then UrlSyncManager takes care of replacing that history with any updates to variables. This will include multiple updates, and chain of updates. Any changes to URL that are not coming from the user action will be automatically added to the current history.

Alternative approaches:

  1. Push history change on each variable change or every time the URL changes.

That can work fine only when there's one variable updated upon user action. When there are multiple updates, they need to be somehow batched into single entry and it's tricky because the update is asynchronous. It's also visible during loading initial state as it usually requires multiple vars to be updated.

  1. Use drilldowns

That works fine but requires some boilerplate just to support it in our case (SceneApp and SceneAppPage). It works great when it's used with native tabs coming with scenes but we don't use it for exploration type. Hence, to support drilldowns it would require even more boilerplate to create correct URLs for each exploration type (basically duplicating something that scenes handles internally with preserverUrlKeys

  1. Change the variable via URL directly

Instead of calling variable.changeValueTo we can change the value directly by modifying the URL. Dashboards do it to handle history (example). This way there's no need to depend on location.partial happening after pushing a new entry (like in this PR). The drawback might be that if a variable name is not unique it's hard to determine it's name as it's mapped by UrlSyncManager/UniqueUrlKeyMapper.

πŸ§ͺ How to test?

It requires manual testing. Please try changing elements mentioned above (and others) and see if back/forward buttons behave as expected.

if (typeof values.explorationType === 'string' && values.explorationType !== this.state.explorationType) {
stateUpdate.explorationType = values.explorationType as ExplorationType;
const type = values.explorationType as ExplorationType;
this.setExplorationType({ type });
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Switching to the getter to get the body updated correctly when going back in history

Copy link
Contributor

Choose a reason for hiding this comment

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

πŸ‘πŸΎ The only drawback is that the same body is built twice - only when landing on the page for the 1st time, if there's an explorationType parameter in the URL.

Copy link
Contributor

Choose a reason for hiding this comment

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

On one side, we should prevent it in onActivate:

    if (!this.state.explorationType) {
      this.setExplorationType({
        type: SceneProfilesExplorer.DEFAULT_EXPLORATION_TYPE,
      });
    }

And on the other side, updateFromUrl should become:

  updateFromUrl(values: SceneObjectUrlValues) {
    if (typeof values.explorationType === 'string' && values.explorationType !== this.state.explorationType) {
      const type = values.explorationType as ExplorationType;
      
      this.setExplorationType({
        type: Object.values(ExplorationType).includes(type) ? type : SceneProfilesExplorer.DEFAULT_EXPLORATION_TYPE,
      });
    }
  }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks! Should be fixed by c66235c

Copy link
Contributor

github-actions bot commented Aug 23, 2024

Unit test coverage

Lines Statements Branches Functions
Coverage: 10%
10.63% (476/4477) 8.24% (136/1650) 7.99% (109/1363)

@@ -103,6 +104,9 @@ export class ProfileMetricVariable extends QueryVariable {
onSelect = (newValue: string) => {
reportInteraction('g_pyroscope_app_profile_metric_selected');

if (!this.state.skipUrlSync) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Checking skipUrlSync because we use this variable with skipping in other place

Copy link
Member

Choose a reason for hiding this comment

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

another variant would be

 if (this.state.skipUrlSync) {
    this.changeValueTo(newValue);
} else {
  locationService.partial({ [`var-{this.state.name}`]: newValue })
}

need to URL encode the value there.

doing it this way would be compatible (not conflict) with any built-in browser history history support in UrlSyncManager / interfaces

@@ -14,7 +14,7 @@ export function useUrlSearchParams() {
newSearchParams.set(key, value);
}

history.push({ search: newSearchParams.toString() });
history.replace({ search: newSearchParams.toString() });
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Switching to replace to avoid creating redundant history items

@ifrost ifrost marked this pull request as ready for review August 23, 2024 11:17
@ifrost
Copy link
Contributor Author

ifrost commented Aug 23, 2024

cc @torkelo @dprokop, would be great to get your feedback on this approach as well to check if we're not setting ourselves for failure and if there's a better approach.

@ifrost ifrost requested a review from grafakus August 23, 2024 11:19
@@ -225,6 +223,7 @@ export class SceneProfilesExplorer extends SceneObjectBase<SceneProfilesExplorer
item?: GridItemData;
}) {
if (comesFromUserAction) {
prepareHistoryEntry();
Copy link
Member

Choose a reason for hiding this comment

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

could this not just do a locationService.partial as well (you would get history step and update state in one action)

Copy link
Member

@torkelo torkelo Aug 23, 2024

Choose a reason for hiding this comment

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

this is what we do for most URL synced "view" state in core dashboards (update the URL via normal link or locationService)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You mean to update the URL instead of changing the state and let the updateFromUrl handle the update? πŸ€” Does it mean you need to know the final name of the variable when doing locationService.partial()? How do you handle it if variable name may be different after processing with urlKeyMapper.getUniqueKey in UrlSyncManager?

Copy link
Member

Choose a reason for hiding this comment

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

true, you would need t know the real URL key (so would only work for things where you know this).

Was just an idea to automatically get a history step for a user action

been making progress on grafana/scenes#760 , can you check it out?

@@ -103,6 +104,9 @@ export class ProfileMetricVariable extends QueryVariable {
onSelect = (newValue: string) => {
reportInteraction('g_pyroscope_app_profile_metric_selected');

if (!this.state.skipUrlSync) {
Copy link
Member

Choose a reason for hiding this comment

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

another variant would be

 if (this.state.skipUrlSync) {
    this.changeValueTo(newValue);
} else {
  locationService.partial({ [`var-{this.state.name}`]: newValue })
}

need to URL encode the value there.

doing it this way would be compatible (not conflict) with any built-in browser history history support in UrlSyncManager / interfaces

@grafakus
Copy link
Contributor

grafakus commented Aug 26, 2024

Thank you for the PR! It works as expected except in two cases:

  1. In "Labels": after clicking on the "Select" panel action:
    image

We should also call prepareHistory when changing the groupBy variable value

  1. In "Labels" and "Flame graph", when there are both the "Service" and the "Profile type" dropdowns, here's a short video:
    https://github.com/user-attachments/assets/e18ac321-0d42-4a11-8b36-62a7cffeb338

@ifrost
Copy link
Contributor Author

ifrost commented Aug 26, 2024

Thanks @grafakus πŸ™Œ

  1. In "Labels": after clicking on the "Select" panel action:
    We should also call prepareHistory when changing the groupBy variable value

Great catch! I've added prepping history directly toselectLabel. It's easy to make errors like this, though and forgot that variable may be change in other place, though :( I wonder if there's some other pattern that could ensure all user actions go via the same route (e.g. I noticed that select label has no user tracking, should we add it?)

Should be fixed by 584db4b

  1. In "Labels" and "Flame graph", when there are both the "Service" and the "Profile type" dropdowns.

Another nice catch! Turns out when variables are updated from the URL both loading and value property are updated straight away and options are loaded with a delay. However, once options are loaded we do not re-render the Cascader (it's forced only when value and loading prop change). It all feels a bit hacky but seems like it's all because of the way Cascader works. There's no way to control it and pass new options and value without forcing a re-render.

Changed in e55d05e

Alternatively we can also revert adding a new history for service and profile for now and think how to unhack Cascader first πŸ€”

@grafakus
Copy link
Contributor

grafakus commented Aug 28, 2024

@ifrost thank you for making the changes!

I noticed that select label has no user tracking, should we add it?

The tracking is centralized in SelectAction

Alternatively we can also revert adding a new history for service and profile for now and think how to unhack Cascader first πŸ€”

I suppose what's lacking is to be able to control the Cascader value... In the meantime, if we end up creating a key by concatenating several values and stringifying options, I'd rather go with a simpler hack to force a render each time:

    return (
      <Cascader
        // TODO: ...
        key={Math.random()}
...

It shouldn't be a big perf issue, wdyt?

@ifrost
Copy link
Contributor Author

ifrost commented Aug 28, 2024

It shouldn't be a big perf issue, wdyt?

It should be fine. We may get some additional renders (2 or 3) on first paint but shouldn't have a big impact. Essentially we need to re-render Cascader when value, loading state or options change which is anyway is the only props that cause the re-render of the parent component (and hence Cascader too) so it should have almost identical outcome. Updated in c05ef57

@grafakus
Copy link
Contributor

Thank you for helping on this @ifrost!

@grafakus grafakus merged commit 5439ab3 into main Aug 29, 2024
5 checks passed
@grafakus grafakus deleted the ifrost/support-browser-history branch August 29, 2024 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Using the browser back and forwards buttons don't work as expected
3 participants