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

Add context-like solution to avoid props drilling #775

Closed
MaxKrai opened this issue Nov 16, 2021 · 9 comments
Closed

Add context-like solution to avoid props drilling #775

MaxKrai opened this issue Nov 16, 2021 · 9 comments

Comments

@MaxKrai
Copy link

MaxKrai commented Nov 16, 2021

Good day to all!
Is it possible from performance perspective and workload to provide support for React/Angular context-like functionality?
For more than 2 years of work with Ember I have come across an urgent need for this functionality several times (mostly large components with different configurations).

I know there are some solutions (e.g. ember-context), but some of them don't have full functionality or don't work with Glimmer components.

Ideally I would like to see something similar to:

<Context data={{someInstance / factory}}>
  <AComponent>
    <DeepComponent />
  <AComponent/>
</Context>

where in deep-component I can use this.context.

Could someone share any information about this functionality, what were the proposals?
As well as could you tell how you deal with this props drilling effect?

I am sure that similar questions have already been asked, sorry for that.

@NullVoxPopuli
Copy link
Sponsor Contributor

NullVoxPopuli commented Nov 16, 2021

I am sure that similar questions have already been asked, sorry for that.

you're good! no worries

Could someone share any information about this functionality, what were the proposals?

There are a couple similar implementations floating around in library form:

I think @pzuraq was maybe looking at implementing something natively? but I don't recall atm.

As well as could you tell how you deal with this props drilling effect?

There are a few ways:

  • services - these can be injected wherever and don't require a provider in your render tree -- but the down side is that you can't have multiple instances based on your component hierarchy.

more information on services here: https://guides.emberjs.com/release/services/

  • provider components -- this is maybe better illustrated through example -- but the gist is that it involves flattening the render tree in the top level calling context, like, to use your example:
    <Context as |context|>
      <AComponent>
        <DeepComponent @data={{context}}>
          <InternalToDeepComponent @data={{context}} />
         </DeepComponent>
      </AComponent>
    </Context>

More information on yielding data here: https://guides.emberjs.com/release/components/block-content/#toc_block-parameters

  • lastly, prop-drill only one arg, via container object / class -- this is similar to the flattening above
 export default class MyComponent extends Component {
   lotsOfData = {
     ...
   }
 }
 <Top @data={{this.lotsOfData]}>
    <AComponent>
      <DeepComponent @data={{this.lotsOfData}}>
        <InternalToDeepComponent @data={{this.lotsOfData}} />
      </DeepComponent>
    </AComponent>
  </Top>

I know this doesn't solve every problem contexts are use for, but these patterns due help mitigate prop-drilling, specifically.

For things that we don't yet have good answers for:

  • nested keyboard shortcut layering (we have ember-keyboard, but the way shortcut nesting works is unintuitive)

  • graphics (where you may have a good deal of layers between your top level "scene" and leaf components that need to interact with that "scene" object) -- this, "so far", has been mitigated via currying:

    {{!-- let's say this as a Scene component --}}
    {{yield 
      box=(component 'box' scene=this.scene)
      camera=(component 'camera' scene=this.scene)
    }}

    Usage:

    <Scene as |s|>
      <s.Box/>
      <s.Camera/>
    </Scene>

@MaxKrai
Copy link
Author

MaxKrai commented Nov 17, 2021

@NullVoxPopuli thank you for the quick and detailed answer!
Looks like we use all of this approaches and in some cases this is not enough.
Let's say we have a lot of no-block-content components in the Card component and we render Cards collection:

<div>Panel</div>
<Photo @photo={{this.photo}} />
<MainInfo @info={{this.info}} />
<MainSection @section={{this.section}}/>

each of children has other components mixed with the common markup (not simple to transform it via provider-like way) etc etc.
Maybe some gaps of this kind may be due to the wrong architecture, but as for large visual components, like configurable widgets, sometimes small parts of the general configuration are needed in many places at the lowest level.

Technically it's not a big problem to transform it to

<div>Panel</div>
<Photo @photo={{this.photo}} @context={{this.context}} />
<MainInfo @info={{this.info}} @context={{this.context}} />
<MainSection @section={{this.section}} @context={{this.context}} />

I really enjoy your route based approach and would like to see the same with @withContextualServices(LocalService) for component but I know that glimmer component doesn't have any references to parent/children. alexlafroscia/ember-context is based on shared key, so technically when we have a collection of components it will be similar to provide @sharedKey or @context.

I think that's why it would be nice to have native support for such functionality.

@NullVoxPopuli
Copy link
Sponsor Contributor

I know that glimmer component doesn't have any references to parent/children

while true, my addon doesn't require those (I think?) it's been a while

I think that's why it would be nice to have native support for such functionality.

I agree

Though, I think a discussion missing from this conversation is public vs private api for the consumer of tthe components.

Imo, contexts are a private detail that the consumer of your components shouldn't have to know about. If any component requires an external context provider to be added, that's a leakage of responsibility, and should be avoided (because it means the user can screw it up, and we want to protect them).

that said, It is common for component authors to jump through whatever hoops they need to in order to make the public api as nice as possible. (cause it's worth it!)

@MaxKrai
Copy link
Author

MaxKrai commented Nov 17, 2021

while true, my addon doesn't require those (I think?) it's been a while

That's true, it doesn't. I meant my case when it's needed to create a service that's private to a component, not to a route (and have N instances for N components under 1 route).

that's a leakage of responsibility, and should be avoided

I agree. From this it does not look good. Yeah, although it violates some principles, nevertheless, it would be useful.

In case of emergency interfaces and strict assertion can be added to avoid incorrect usage of public api:

export default class GlimmerComponent<Args extends {} = {}, Context extends {} = {}> extends _GlimmerComponent<Args, Context> {
throw new Error('You must pass a context....')

@simonihmig
Copy link
Contributor

Hey there,

so I would also be highly interested in this. My use case is not prop drilling, but what @NullVoxPopuli already touched above: declaratively rendering to a DOM-less world (here WebGL, but could also be a 2D canvas context or some maps API). What you need there is managing a hierarchy of nodes within a large tree (e.g. a "Scene").

This is covered in more detail in my previous RFC issue #597 (the first part of "Managing hierarchies"). That would be possible to support either via an API that exposes the parent component instance (e.g. through some opt-in component manager capability, so that not every component has this (abusable) feature), or a context API. If you have one of these, you could basically based on that implement the other one as well. But a context API would be perfectly sufficient for this use case. (e.g. the recently released svelte-cubed does the same)

So whatever the underlying primitive should be, I would be highly interested in dedicating time to move this forward (e.g. draft a RFC). But would certainly need guidance on the technical direction (no in-depth GlimmerVM knowledge). For example instead of designing a full-fledged context API, we could maybe also - as we have done before - only expose a low-level primitive (aka manager) that can be used to implement this in a user-land addon (leaving room for more experimentation).

Not that I have that much time, but the lack of this features is just a - sorry - PITA and makes me loose time (I use an ugly AST template transform to workaround the limitations, but that comes with its own set of problems). Also quite unfortunate that it seems Ember is the only major framework that does not properly support the use case of declarative 3D rendering, while others provably do (e.g. react-three-fiber, svelte-cubed, vuel-gl etc.)

For the record, I found this related discussion on Discord between @pzuraq and @GCheung55 (hope I picked the right GH handle?).

@pzuraq as we had some chats about this topic before, would you be able and willing to be that RFC "champion"? Or anyone else here?

@wagenet
Copy link
Member

wagenet commented Jul 23, 2022

I'm closing this due to inactivity. This doesn't mean that the idea presented here is invalid, but that, unfortunately, nobody has taken the effort to spearhead it and bring it to completion. Please feel free to advocate for it if you believe that this is still worth pursuing. Thanks!

@kevinkucharczyk
Copy link

kevinkucharczyk commented Sep 26, 2023

Hi!

I wonder if anyone put any more thought into potentially adding a context-like API to Ember recently?

I recently came across https://github.com/alexlafroscia/ember-context, but the caveats listed in the addon probably don't make it a viable option for us in production. So I started looking into Glimmer internals, and found the DebugRenderTree, which - I believe - is used only for Ember Inspector.

DebugRenderTree contains everything that would be needed to create a context API - it keeps track of parent/child relationships between components. However, it also does more processing than that, so I don't want to use this in production either.

I created a small proof of concept over on this branch: https://github.com/customerio/glimmer-vm/tree/provide-consume-context (see the diff here), where I built a context API backed by a ProvideConsumeContextContainer class.

It's sort of a very simplified version of the DebugRenderTree, but without all the extra bounds/nodes processing. For each component rendered, it stores a reference to any "context provider" components that it's nested within.

For testing purposes, my context provider component class is

export default class ContextProvider extends Component {
  static _isProvideConsumeContextProvider = true;

  get id() {
    // A string id to use for retrieving the provider's value in other components
    return this.args.id;
  }

  get value() {
    // The value to be associated with this provider
    return this.args.value;
  }
}
{{yield}}

Then I built a Consumer component, which looks like this

export default class ContextConsumer extends Component {
  get contextValue() {
    let renderer = getOwner(this).lookup('renderer:-dom');
    let provideConsumeContextContainer = renderer._runtime.env.provideConsumeContextContainer;

    // Retrieve the "contexts object" that was attached to the instance of this component
    let contexts = provideConsumeContextContainer.contexts.get(this);

    // "contextId" is the same string used in a ContextProvider. This returns the actual instance of the rendered Provider component
    let providerComponentInstance = contexts[this.args.contextId];
    // return the value from the provider
    return providerComponentInstance?.value;
  }
}

This abuses some Ember internal APIs, but overall the approach seems to work. The core of contexts is really passing all provider values down the component tree.

Could we introduce an API like this into Glimmer/Ember proper? Is there anything obviously wrong with this approach? I admit I'm not at all familiar with the intricacies of how Glimmer renders things, so I wouldn't be surprised if this doesn't work in some scenarios.

And a note on motivation: in the Ember world, contextual components and services are usually how we handle context-like scenarios. However, this doesn't work well when we want to expose certain states down a whole component tree without having to pass the state as argument through all layers.

For example, we're building a new design system/component library internally, and context would allow us to make more magic happen within that library. Some of our components accept a background color property - with context, we can have all child components automatically switch to the correct text color or background color to match the "context" they're in. Again as an example, an "error" alert with a red background would be a context provider, and all button components inside it would also choose the appropriate error/red state appearance to match.

Locally scoped CSS variables can do some of that, but for more complex components we'd need too many special variables to make it work. A button component needs its text color, background color, border color AND all the attached hover/focus/active states changed, depending on what sort of background it's on.

@NullVoxPopuli
Copy link
Sponsor Contributor

I think it's worth pursuing! Can you open an rfc pr?

No need to get too detailed initially, you could start with copying your comment content in to the new file in the pr!

@kevinkucharczyk
Copy link

@NullVoxPopuli RFC PR here: #975

Thanks for the encouragement!

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

No branches or pull requests

5 participants