Skip to content

Context#1200

Open
NullVoxPopuli wants to merge 2 commits into
mainfrom
nvp/context
Open

Context#1200
NullVoxPopuli wants to merge 2 commits into
mainfrom
nvp/context

Conversation

@NullVoxPopuli

@NullVoxPopuli NullVoxPopuli commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Implementation: emberjs/ember.js#21450

Intent to supersede

Propose Context

Rendered

Summary

This pull request is proposing a new RFC.

To succeed, it will need to pass into the Exploring Stage, followed by the Accepted Stage.

A Proposed or Exploring RFC may also move to the Closed Stage if it is withdrawn by the author or if it is rejected by the Ember team. This requires an "FCP to Close" period.

An FCP is required before merging this PR to advance to Accepted.

Upon merging this PR, automation will open a draft PR for this RFC to move to the Ready for Released Stage.

Exploring Stage Description

This stage is entered when the Ember team believes the concept described in the RFC should be pursued, but the RFC may still need some more work, discussion, answers to open questions, and/or a champion before it can move to the next stage.

An RFC is moved into Exploring with consensus of the relevant teams. The relevant team expects to spend time helping to refine the proposal. The RFC remains a PR and will have an Exploring label applied.

An Exploring RFC that is successfully completed can move to Accepted with an FCP is required as in the existing process. It may also be moved to Closed with an FCP.

Accepted Stage Description

To move into the "accepted stage" the RFC must have complete prose and have successfully passed through an "FCP to Accept" period in which the community has weighed in and consensus has been achieved on the direction. The relevant teams believe that the proposal is well-specified and ready for implementation. The RFC has a champion within one of the relevant teams.

If there are unanswered questions, we have outlined them and expect that they will be answered before Ready for Release.

When the RFC is accepted, the PR will be merged, and automation will open a new PR to move the RFC to the Ready for Release stage. That PR should be used to track implementation progress and gain consensus to move to the next stage.

Checklist to move to Exploring

  • The team believes the concepts described in the RFC should be pursued.
  • The label S-Proposed is removed from the PR and the label S-Exploring is added.
  • The Ember team is willing to work on the proposal to get it to Accepted

Checklist to move to Accepted

  • This PR has had the Final Comment Period label has been added to start the FCP
  • The RFC is announced in #news-and-announcements in the Ember Discord.
  • The RFC has complete prose, is well-specified and ready for implementation.
    • All sections of the RFC are filled out.
    • Any unanswered questions are outlined and expected to be answered before Ready for Release.
    • "How we teach this?" is sufficiently filled out.
  • The RFC has a champion within one of the relevant teams.
  • The RFC has consensus after the FCP period.

@github-actions github-actions Bot added the S-Proposed In the Proposed Stage label Jun 10, 2026
@les2

les2 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

For consuming, an alternative API could be:

@service
someThing;

on the provider side, you could use:

@provide('key')
get someThing() {
   return 'hi';
}

Use case I'm thinking of:
I often have some API for my component and i need to pass it down multiple layers.

class Modal extends Component {
   @tracked closed = false;
   
   @provide('service:modalApi')
   get modalApi() {
      return { close: () => { this.closed = true;}; } 
   }
}

For anything that's embedded in a modal, they could react to it (optionally):

class Thing extends Component {
   @service('modalApi', { optional: true })
   modalApi;
   
   <template>
       {{#if this.modalApi}}<h1>i'm in a modal!</h1>{{else}}<p>I'm not.</p>{{/if}}
   </template>
}

Of course, the tag-based / template-based <Provide API works too for cases when you don't have a backing class.

This allows only the provider-side to adopt new APIs (if service is required, it's just @service modalApi; You could also replace / shadow any service for children. This is very evil but I could see it being useful.

If overloading the behavior of @service is not appreciated (I suspect the majority will instinctively find it to be a terrible idea) -- you could just use a @consume decorator instead.

On the consumption side, I think that considering "consume" in the abstract just a service is pretty cool.

In certain backend frameworks, you can declare "services" with scopes -- session-scoped, request-scoped, or singleon/global -- the consumer doesn't decide that, only the provider. The framework injects the appropriate object as configured.

  • Optionality of the consumed quantity will be useful.

--- Okay, that's enough rambling. ----

Writing this up in a way that I thought about it helped me understand the demand for the feature in the proposal. The bikeshedding I have provided on the shape of the solution doesn't impact the core need for such an API. As such, I hope this proposal succeeds.

@les2

les2 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

I sometimes -- perhaps just as often if not more -- want the reverse!

The children provide something for the parent. This calls for the "dreaded" (IMO) <Foo @registerApi={{this.setFooApi}} /> pattern that allows the host component for <Foo to get a reference to it.

// in the host
@consume('fooApi') foo;

<template><Foo /></template>

maybe foo does:

class Foo extends Component {
   @provide('fooApi')
   get foo() { return this.makeApi(); }
   
   // alternatively
   constructor() {
      super(...);
      provide(this, 'fooApi:' + this.args.name, this.makeApi());
   }
}

This is offtopic! just wanted to say there's an inverse provide/consume relationship that requires boilerplate.

@NullVoxPopuli

NullVoxPopuli commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

consume can already work in the class, but not as a decorator -- no argument is needed, it's just ctx.consume() and you get the value.

on Provide using a decorator -- it breaks the "block scope" semantics that we get with the DOM, so I feel it would be too magical, and potentially leaky, to implement that way.

on decorators in general, for type safety, it's easier to avoid them since we can't have decorators force a type on their decorated property.

This is offtopic! just wanted to say there's an inverse provide/consume relationship that requires boilerplate.

your example from your second comment would be written as:

const fooApi = makeContext();

class Foo extends Component {
  makeApi = () => {/* ... */};

  <template>
    <fooApi.Provide @value={{ (this.makeApi) }}>
      {{yield}}
    </fooApi.Provide />
  </template>
}

<template>
  <Foo>
    {{log (fooApi.consume)}} -- logs whatever makeApi returns
  </Foo>
</template>

@patricklx

Copy link
Copy Markdown

could it be enhanced to support keyed values? non-keyed could also just work

<template>
<fooApi.Provide @key="api" @value={{ (this.makeApi) }}>
  <fooApi.Provide @key="theme" @value={{ (this.makeTheme) }}>
      {{(ctx.consume 'api')}}
      {{(ctx.consume 'theme')}}
  </fooApi.Provide />
</fooApi.Provide />
</template>

consume would walk up until it find the right provider

@NullVoxPopuli

NullVoxPopuli commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

String keys are hard to type (both in the typescript sense and in the avoiding typos sense), so I'd encourage folks to do something like this for your example

let api = makeContext();
let theme = makeContext();

<template>
  <api.Provide @value={{ (this.makeApi) }}>
    <theme.Provide @value={{ (this.makeTheme) }}>
      {{(api.consume)}}
      {{(theme.consume)}}
    </theme.Provide />
  </api.Provide />
</template>

@rtablada

rtablada commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Decorator Feedback

@consume is a shorthand

From a perspective of API @consume as a decorator is fine and could be written as a fairly simple higher order function in user/addon land and doesn't need to be part of framework API (at least in my opinion)

import { currentUserContext } from '#/contexts';

class MyThing extends Component {
  @consume(currentUserContext)
  declare currentUser;
}

// Under the hood this decorator would be the same as doing
import { currentUserContext } from '#/contexts';

class MyThing extends Component {
  get currentUser() {
    return currentUserContext.consume();
  }
}

@provide is a hard/bad API for declaring providing

@provide as a decorator is poor API from both a code clarity and implementation standpoint.

Poor code clarity

From a code clarity standpoint @provide being decorated on a class doesn't answer when/where you're providing in the hierarchy of a component.
Take for example:

import { currentUserContext } from '#/contexts';

class MyThing extends Component {
  @tracked
  currentUserImplementation = new User();

  @provide(currentUserContext)
  get currentUserForChildren() {
    return this.currentUserImplementation;
  }

  <template>
    <NavBarThatUsesCurrentUser />
  </template>
}

Now the setup and execution of provide isn't clear when or where it is providing a value. Constructor execution order now is also changing the semantics of if the provided context will get properly tracked.

Readability gets worse when you then consume from the same context in your current component.

import { currentUserContext } from '#/contexts';

class MyThing extends Component {
  @tracked
  currentUserImplementation = new User();

  @provide(currentUserContext)
  get currentUserForChildren() {
    return this.currentUserImplementation;
  }

  get unknownValue() {
    return currentUserContext.consume();
  }

  <template>
    <NavBarThatUsesCurrentUser />
  </template>
}

In this example the consumed value in unknownValue isn't really clear... Should it get the locally provided value? Should it be from parent stack?

Compare this to the proposed solution of having providers that must be in explicit template rendering stack:

import { currentUserContext } from '#/contexts';

class MyThing extends Component {
  @tracked
  currentUserImplementation = new User();

  get unknownValue() {
    return currentUserContext.consume();
  }

  <template>
    <currentUserContext.Provide @value={{this.currentUserImplementation}} >
      <NavBarThatUsesCurrentUser />
    </currentUserContext.Provide>
  </template>
}

Now it is clear that the provider is changing the looked up value for things like the NavBarThatUsesCurrentUser within the provider, but MyThing will not be inside of the provider so gets values from up the stack (if you do want to reference currentUserImplementation in MyThing you can always use this.currentUserImplementation

Increased implementation scope

Having @provide as a decorator also severely increases the scope and complexity around life cycles.
Now when you @provide you are essentially overwriting the behavior of the current component template, you are also adding extra hooks that must be run and evaluated for lifetime scope for proper setup/teardown of every individual decorator which has been added.

While @consume is essentially a shorthand for adding a getter, @provide has intricate and complex interaction with rendering, lifecycles, etc.


Keyed Contexts

@NullVoxPopuli and I pushed for explicit createContext which mimics React/Preact/Svelt rather than keyed context lookup (Vue's inject or Svelte's setContext)
This has benefits for clarity and reducing typos as well as Typescript support, it also makes lookup for values a bit clearer and cleaner in framework implementation as it means keys in internal stack/Weakmap representations are a deterministic context object rather than a string key.

Note

It should be clear here that the someContext.Provide component only provides for that particular context and no others, this can't be overridden. This is unlike ember-provide-consume-context today where there is a single ContextProvider component imported from the library.

Types and code clarity

Because each context is typed individually when calling createContext, you automatically get type hints builtin without any type registry, generated file, etc (see ember-provide-consume-context or vue where registries or extensive as WhateverContextType are required for types).

This is super important for type checking when setting @value when using provide components since as/satisfies type hints are not available in GTS.

For reference:

const userContext = createContext<User>();

const ryan = new User();
const ember = new Framework();

<template>
  {{!-- This is ok by types --}}
  <userContext.Provide @value={{ryan}}>
    ... Stuff...
  </userContext.Provide>

  {{!-- This would throw a type error because Framework is not a User --}}
  <userContext.Provide @value={{ember}}>
    ... Stuff...
  </userContext.Provide>
</template>

@rtablada

Copy link
Copy Markdown
Contributor

Questions

  1. Why the preference for myContext.consume vs the react/preact api of consume(myContext)?

Recommendation: Clarify that opinions could be done in user land

There are a few things that people might disagree with but are fully able to be implemented in user land.

  1. Thrown on not found:

Users who do still want the undefined behavior in their use cases could implement their own higher order functions for instance consumeWithDefault or consumeSafely:

function consumeWithDefault<T>(value: Context<T>, defaultValue: T): T {
    try {
        return value.consume();
    } catch {
        return defaultValue;
    }
}

function consumeSafely<T>(value: Context<T>): T | undefined {
    try {
        return value.consume();
    } catch {
        return undefined;
    }
}
  1. Test helpers

The RFCs declare that no test helpers would be added. It's fairly common to setup a provider in a beforeEach or other block so that it does not have to be repeated for every render call. It would be helpful to show how a wrapInContext or some other test helper could be written.

@rtablada

Copy link
Copy Markdown
Contributor

Nitpick

I know that there's already bikeshedding around export location. But, @ember/helper feels a bit wrong for two main reasons:

  1. /helper - These APIs generally are used outside of {{}} blocks and are usable in pure JS so don't really fit the helper mold
  2. @ember - To me as a consumer of packages, @ember generally implies I need to have Ember.Application which is not the case here. So prefer using @glimmer namespace

My preference is @glimmer/context as it shows that this is lower level than requiring Ember application and works with raw Glimmer components and resource tracking. @ember/context by itself would be a close second

Short of that, since this is closely tied to the newer renderComponent API, @ember/rendering could be a decent spot.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

Why the preference for myContext.consume vs the react/preact api of consume(myContext)?

just to show where it's coming from.

You can absolutey do

const api = makeContext();

export const Provider = api.Provide;
export const consume = api.consume;

consume(myContext)

additionally this is extra ceremony that I don't think is useful (you need two imports now instead of one). since we can have an explicit reference, we should use it.

My preference is @glimmer/context ... @ember/context

My hesitancy here is that this would be a brand new package with only one export

@ember/renderer

I originally had it here, actually 🙈 but, was thinking -- context isn't a rendering concern from the user's perspective

@rtablada

Copy link
Copy Markdown
Contributor

Note on keys

Svelte has both createContext and an older string based keys getContext/setContext API and recommends the createContext API due to types https://svelte.dev/docs/svelte/context#setContext-and-getContext

createContext is preferred since it provides better type safety and makes it unnecessary to use keys.

@rtablada

Copy link
Copy Markdown
Contributor

Testing Helpers

The RFC mentions not having any framework level testing helpers.

Generally we can learn from Svelte again here https://svelte.dev/docs/svelte/context#Component-testing

let testRender;

hooks.beforeEach(() => {
    const defaultUser = new User();

    testRender = (children) => {
        return render(<template>
            <userContext.Provide @value={{defaultUser}}>
                <Children />
            </userContext.Provide>
        </template>)
    }
})

test('foo', async () => {
    await testRender(<template><MyThing /></template>);
		// Test stuff
})

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

Labels

S-Proposed In the Proposed Stage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants