Skip to content

Scopes: Add ScopesBridge object#990

Merged
aocenas merged 19 commits intomainfrom
bogdan/react-contexts-bindings-poc
Mar 11, 2025
Merged

Scopes: Add ScopesBridge object#990
aocenas merged 19 commits intomainfrom
bogdan/react-contexts-bindings-poc

Conversation

@bfmatei
Copy link
Contributor

@bfmatei bfmatei commented Dec 4, 2024

Continuing the discussion from here: grafana/grafana#97176

So what this PR brings: allow SceneObjects to consume React Contexts.

📦 Published PR as canary version: 6.3.1--canary.990.13785017927.0

✨ Test out this PR locally via:

npm install @grafana/scenes-react@6.3.1--canary.990.13785017927.0
npm install @grafana/scenes@6.3.1--canary.990.13785017927.0
# or 
yarn add @grafana/scenes-react@6.3.1--canary.990.13785017927.0
yarn add @grafana/scenes@6.3.1--canary.990.13785017927.0

Copy link
Collaborator

@torkelo torkelo left a comment

Choose a reason for hiding this comment

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

I think this is really cool, but until we have more examples / stronger need for this for many contexts I would recommend a more simple specific implementation for the ScopeContext specifically (maybe a base class that can handle some generic stuff).

Like a ScopeContextConsumer scene object that you can add to your scene (we would add it to DashboardScene), maybe we add it as an optional state prop to EmbeddeddScene or SceneApp (DashboardSceneRenderer would have render the ScopeContextConsumer component to make the context => scene state active).

Adding this to all scene objects, and to ComponentWrapper feels a bit too drastic until we know how much this will be used

@torkelo
Copy link
Collaborator

torkelo commented Dec 4, 2024

But I do think something like ScopesContextConsumer or ScopesSceneBridge (whatever we call it), could be something we add to scenes lib (and it depends on the context in runtime). And then scene app plugins can use it

Copy link
Collaborator

@dprokop dprokop left a comment

Choose a reason for hiding this comment

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

Agree with Torkel overall, this is a neat addition, but maybe would be worth talking it trough more widely if there's really a library need for this. It introduces quite a bit of complexity, and I'm not sure if we should solve this problem in classic scenes, but rather in react-scenes.

Comment on lines +46 to +52
function SceneComponentWrapperWithoutMemo<T extends SceneObject>({ model, ...otherProps }: SceneComponentProps<T>) {
return [
<ContextsConsumer key="contexts" model={model} />,
<ComponentRenderer key="component" model={model} {...otherProps} />,
];
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Took me a bit to udnerstand this 🤔

@bfmatei
Copy link
Contributor Author

bfmatei commented Dec 5, 2024

@torkelo @dprokop

I refactored this to only use React Context for Scopes (added a bridge between scopes and scenes).

A couple of notes:

  • SceneApp seems the best place to place the bridge as it's top level, meaning that all nested scene objects can get it
  • SceneAppPage have a useScopes flag that control wether a page should or should not show the scopes selector
  • the bridge also handles URL sync as it's way better than controlling it via the service (I can change this tho')
  • added usage for SceneQueryRunner as well as adhoc variables and groupby variables. No more double queries and no more data/filters enriching needed
  • the context is defined here for testing purposes as it's easier than to link runtime to scopes and then scopes to grafana while doing development
  • the useScopes hook has to spread the context as simply returning the context won't take effect for use-cases like
useEffect(() => {
  console.log(context);
}, [context]);
  • the context has to be a service as it's way easier to offer interoperability between scopes dashboards, scopes selector and the scopes "facade"
  • we need to do something with Prettier. Apparently there are a lot of files which are not formatted correctly 😢

Copy link
Collaborator

@torkelo torkelo left a comment

Choose a reason for hiding this comment

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

I think this looking really good

I think the isScopesLoading code added to AdHocFilters and GroupByVarable feel a bit on the "too complex" / detail that it should not have to care so deeply about (ie subscribe to state etc).

Wonder if we can skip that for now, and if it becomes a problem find a less invasive solution (like a loading spinner on the whole scene until scopes have initialized)

private _onActivate() {
if (this.isQueryModeAuto()) {
const timeRange = sceneGraph.getTimeRange(this);
const scopesBridge = sceneGraph.getScopesBridge(this);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Long term I think it would be cool to generalize this concept with a special variable type that all query runners and variables depend on by default (so will block initial query execution), and the variable can then modify the query.

I tried to start a poc around it, a QueryEnhancerVariable but did not get very far.

But I think this implementation is also ok, just a shame it adds more complexity to this already complex class :)

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 have something in mind regarding this. I'll start a PoC in the next weeks 🤔 .

Copy link
Contributor Author

@bfmatei bfmatei left a comment

Choose a reason for hiding this comment

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

I'll just continue doing testing for this and see how it goes. I also need to fix the code in core and test that out as well.

private _onActivate() {
if (this.isQueryModeAuto()) {
const timeRange = sceneGraph.getTimeRange(this);
const scopesBridge = sceneGraph.getScopesBridge(this);
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 have something in mind regarding this. I'll start a PoC in the next weeks 🤔 .

layout?: PageLayoutType;

// Whether to use scopes for this page
useScopes?: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we really need this as a page level prop?

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'm thinking that some pages might not want scopes to be shown for various reasons 🤔

Copy link
Collaborator

@dprokop dprokop left a comment

Choose a reason for hiding this comment

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

@bfmatei - this looks good now! Excellent job on bringing this to scenes. I've left just a few nits to consider.

}

this.runWithTimeRange(timeRange);
this.runWithTimeRangeAndScopes(timeRange, scopesBridge);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's add tests for making sure the SQR works as expected w/ scopes.

@bfmatei bfmatei force-pushed the bogdan/react-contexts-bindings-poc branch from 6265009 to c3a4b47 Compare December 9, 2024 14:40
@bfmatei bfmatei added patch Increment the patch version when merged release Create a release when this pr is merged labels Dec 9, 2024
@bfmatei bfmatei changed the title ReactContexts: PoC Scopes: Add ScopesBridge object Dec 9, 2024
@bfmatei bfmatei force-pushed the bogdan/react-contexts-bindings-poc branch from 6412637 to 6fbea37 Compare February 17, 2025 09:16
if (this._pendingScopes && newContext) {
setTimeout(() => {
newContext?.changeScopes(this._pendingScopes!);
this._pendingScopes = null;
Copy link
Member

Choose a reason for hiding this comment

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

Should we just return here and end the update? It seems like the _pendingScopes have priority (cause they are from the URL) so we need to propagate them into the context, so it does not seem important what is the current newContext value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Haven't tested out this case. At a first glance I would say we still need to save the new context to get access to other props (i.e. enabled or loading) even if scopes themselves were not loaded.

Something in the lines of:

  • load page with scopes in URL
  • trigger scopes loading in the context which results in setting the loading flag to true
  • the bridge prevents the query runner to perform queries given that scopes are loading


public updateContext(newContext: ScopesContextValue | undefined) {
if (this._pendingScopes && newContext) {
setTimeout(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this timeout needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IIRC it was a race condition between setting new scopes which triggers scopes loading hence setting the loading flag to true and pushing the new context value through _contextSubject.

this.context?.setReadOnly(readOnly);
}

public updateContext(newContext: ScopesContextValue | undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

When setting new scopes, do I have to call this? Wouldn't it be easier with an updateScopes method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

updateScopes was deliberately skipped until we have a need to expose it publicly. This should never be called by plugins. However, marking it as private would prevent us from accessing it in the model.

An alternative would be to expose a SceneScopesBridgeLike interface that only contains public methods and use that as a return value for getScopesBridge or for the state prop in AppComponent.

Copy link
Contributor

Choose a reason for hiding this comment

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

We can't set this to private, as it is used when setting the context from useScope

@bfmatei bfmatei force-pushed the bogdan/react-contexts-bindings-poc branch from ea428ac to 5411c17 Compare February 27, 2025 09:44
Copy link
Collaborator

@mdvictor mdvictor 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 amazing work! Small comment about removing the filter enriching call in adhocs

queries,
timeRange,
scopes: this._scopesBridge?.getValue(),
...getEnrichedFiltersRequest(this),
Copy link
Collaborator

@mdvictor mdvictor Mar 5, 2025

Choose a reason for hiding this comment

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

Should we still have calls to getEnrichedFiltersRequest now that scopes are pulled directly here? Also other related filter enriching code? I'd reckon there's no use for them anymore, e.g.: packages/scenes/src/variables/getEnrichedFiltersRequest.ts

Copy link
Collaborator

Choose a reason for hiding this comment

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

Since the filters enrichers have all been removed here that call won't do anything so I guess we could leave it there for when/if we will ever enrich filters that way again?

Copy link
Collaborator

@mdvictor mdvictor left a comment

Choose a reason for hiding this comment

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

LGTM!

([prevContext, newContext]) =>
[prevContext?.state.value ?? [], newContext?.state.value ?? []] as [Scope[], Scope[]]
),
filter(([prevScopes, newScopes]) => !isEqual(prevScopes, newScopes))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need this filtering here? I've not encountered a scenario where this might happen (e.g.: applying the same scopes from the selector would be one, I think)

But it does cause issues when trying to inject scope filters into adhoc. Basically when the dashboard loads, the scopes aren't yet loaded so they are not pushed to the adhoc, and when subscribeToValue triggers it compares prev and new scopes as being the same since it's the first value anyway. And because of this filtering we don't pass the first scopes values and the adhocs don't get populated

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we get around this by not filtering for undefined context above? I think having this safeguard can prevent unnecessary accidental updates. Maybe we can do a check if it is the first update here? We can do a distinctUntilChanged before mapping to the values. So it will end up emitting [] and [], but at least it will emit a value when going from an undefined context and the loading has finished..

Copy link
Member

Choose a reason for hiding this comment

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

when subscribeToValue triggers it compares prev and new scopes as being the same since it's the first value anyway.

I think you can do something like this though right:


current = SceneScopesBridge.context
SceneScopesBridge.subscribeToValue(() => do something with value)

I feel like both the pairwise and the filters would mess up a bit the BehaviourSubject contract here. pairwise seem to need 2 events until it emits something, if we filter out undefined and loading context values this needs 3 changes to emit first time.

So I would agree that removing the first filter probably makes sense as you want to know if it was undefined before or goes to undefined value just has to be mapped right. Also as this suggests https://stackoverflow.com/questions/50059622/rxjs-observable-which-emits-both-previous-and-current-value-starting-from-first I would start it off with some empty value see this actually emits something from the start.

@aocenas aocenas merged commit 6ed48bf into main Mar 11, 2025
5 checks passed
@aocenas aocenas deleted the bogdan/react-contexts-bindings-poc branch March 11, 2025 10:01
@github-project-automation github-project-automation bot moved this from 🔍 In review to 🚀 Done in Grafana Frontend Platform Mar 11, 2025
@scenes-repo-bot-access-token
Copy link

🚀 PR was released in v6.3.1 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Increment the patch version when merged release Create a release when this pr is merged released

Projects

Status: 🚀 Done

Development

Successfully merging this pull request may close these issues.

6 participants