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

An alternative to Controllers #499

Closed

Conversation

NullVoxPopuli
Copy link
Contributor

@NullVoxPopuli NullVoxPopuli commented Jun 10, 2019

rendered

depends on:

Update (2020-02-08):

Update (2020-03-03):

@lifeart
Copy link

lifeart commented Jun 10, 2019

In current queryParams implementation qp-values binded to model, it's possible to have different states for one page for different models, how we can handle it using new approach?

@NullVoxPopuli
Copy link
Contributor Author

NullVoxPopuli commented Jun 10, 2019

RFC 380 proposes a way to store query params per sub-route :) This is no longer the case, as that RFC is going to be changing how we think about query params. Please read the updates over there :)

the gist is that we'll probably rely on community solutions for caching of query params between routes, maybe like https://github.com/NullVoxPopuli/ember-query-params-service/blob/master/tests/acceptance/navigation-test.ts#L56

@lifeart
Copy link

lifeart commented Jun 10, 2019

@NullVoxPopuli my comment about this behavour https://ember-twiddle.com/b9e78f44f0a63a1a5b0f75ca28b9ebef - try type some search and change models.
each model has personal "queryParams" and if we change model, queryParams also changed

@NullVoxPopuli
Copy link
Contributor Author

NullVoxPopuli commented Jun 10, 2019

Yup. that works with RFC 380 :)

@ef4
Copy link
Contributor

ef4 commented Jun 10, 2019

This is on a good track but needs more detail. Can you expand upon the lookup priority rules?

For example, I think if app/templates/your-route.hbs or app/controllers/your-route.js exist, they probably need to keep doing what they do now. But if they don't, we can add the new behavior to use app/components/your-route.js and/or app/templates/components/your-route.hbs (essentially "the component named "your-route").

That would be safe as long as we gave you an upgrade step that generated explicit template files for any of your classic routes that were previously relying on {{outlet}} being auto-generated for them.

I also think we end up in an inconsistent state if we don't also allow app/templates/slow-model-loading.hbs to become app/templates/components/slow-model-loading.hbs and app/templates/error.hbs to become app/templates/components/error.hbs. As far as I can tell that doesn't require any design other than saying what parameters they will see (like maybe @error).

[Edited: I had a ton of typos in my path examples that made it hard to follow before.]

@jrjohnson
Copy link

The title doesn't really match the RFC @NullVoxPopuli maybe Alternative Controller APIs? Or something less final since you're not actually proposing the removal of controllers

@NullVoxPopuli
Copy link
Contributor Author

@jrjohnson That's fair, removing controllers would require a deprecation RFC, which this is not

@NullVoxPopuli NullVoxPopuli changed the title Remove Controllers An alternative to Controllers Jun 10, 2019
@jasonmit
Copy link
Member

Should the RFC recommend alternative approaches to storing long lived state? I imagine the rouleable components will not be singleton's - correct?

@NullVoxPopuli
Copy link
Contributor Author

NullVoxPopuli commented Jun 10, 2019

@ef4 I've added some details around look priority :)

I also think we end up in an inconsistent state if we don't also allow...

I think addressing loading/error states should happen in a separate RFC. As far as I understand it, error and loading stuff is just templates. I'd like to keep this RFC focused on controllers, unless the community or the core teams say it absolutely needs to be done. idk :)

I've changed my mind. changing the error and loading stuff keeps cohesion :)

(but I'm in favor of @error getting sent to anything error-y) :)

@jasonmit correct. any component used for routing should be considered ephemeral. Services should be used for long-term state. :)

@Panman82
Copy link

FWIW, I feel like the move away from controllers can be aligned with router refactors. In that, once folks move to (potentially) new route semantics, controllers will no longer be used/loaded. Instead, some form of a component will become the route template, such as a template-only component or maybe the route defines which component to render, etc. More thoughts coming as soon as I get to my ember2019 post.

@stefanpenner
Copy link
Member

@jasonmit correct. any component used for routing should be considered ephemeral. Services should be used for long-term state. :)

bingo

@jasonmit
Copy link
Member

jasonmit commented Jun 10, 2019

I imagined the store(s) would be backed by a service but will there be a recommended approach regarding strategy or common API that can be shared? Or is this an opportunity for Ember, as a the framework, to step out of the way and lean more on community solutions?

@NullVoxPopuli
Copy link
Contributor Author

NullVoxPopuli commented Jun 10, 2019

@jasonmit,

I imagined the store(s) would be backed by a service but will there be a recommended approach regarding strategy or common API that can be shared?

service injections cover this :)

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';

export default class MyComponent extends Component {
  @service store;
  @service toast;

  @task(function*() {
    const record = this.store.createRecord('post', { ...whateverAttributes });

    try {
      yield record.save();
      toast.success('yay!');
    } catch (e) {
      toast.error(e);
    }
  }) create;
}
<button {{on 'click' (fn (perform this.create))}}>
  Create a Post
</button> 

@jasonmit
Copy link
Member

jasonmit commented Jun 10, 2019

I'm aware of the service inject API but feel it doesn't quite layer over what we have now.

Currently there's a 1:1 mapping of route backed state vs what sounds like a recommendation for arbitrary service(s) used to hang state from. Uniformity across codebases breaks down here and may be worth documenting recommended paths in the migration docs. Perhaps something for the Ember learning team.

@NullVoxPopuli
Copy link
Contributor Author

what sounds like a recommendation for arbitrary service(s)

hmm, maybe I misunderstand? can you provide some concrete examples of how you use controllers today, that components wouldn't be able to handle?

Uniformity across codebases breaks down here

can you expand on this?

@willviles
Copy link

willviles commented Jun 10, 2019

I understand that it's more a concern that it's a move from a highly conventional approach (FooRoute => FooController), to a more ad-hoc approach (FooRoute => ???Service/s).

Can someone explain why routes themselves can't be singletons and store their own long-lived state? Like a service that just automagically has hooks invoked on route changes?

@jasonmit
Copy link
Member

hmm, maybe I misunderstand? can you provide some concrete examples of how you use controllers today, that components wouldn't be able to handle?

Any state that isn't queryparam state. One example could be an email client that keeps draft state for emails in memory over the lifetime of the app instance. Today, you might throw it in a controller or a service. If you're in a large codebase then there may be many instances like this and you'd end up with really small, oddly shaped, services when migrating an app over to a controller-less world.

So the question I've been failing to pose (sorry, limited by typing on my phone) is are we recommending moving everyone to services for this state or is there a common API that can be shared for mapping route backed state without one off services where a dedicated services may be a bit too heavy.

@ef4
Copy link
Contributor

ef4 commented Jun 18, 2019

I'm OK with explicit import of components and I agree with that general direction, but I do want to point out that it doesn't give us any static analyzability that we don't already have, so long as people are sticking to the conventional names for everything (which they'll now be forced to do since Route#render is deprecated).

Embroider can already statically determine which Route, Controller, and template files go with each route. Having a convention means it's not ambiguous.

So design explicit imports if that's the developer experience we want. But don't design them that way assuming it's needed for static analysis, because it's not.

@chriskrycho
Copy link
Contributor

@ef4 I should clarify that my own emphasis on static analyzability is not for the build pipeline, where I recognize that, as you note, "convention means it's not ambiguous", but for all the other tooling. There, the explicitness of imports is a huge win, as it ends up being a thing we don't have to build ourselves. The fewer things I have to teach a language server to understand/etc. and the more I can just rely on tools' existing support, the better.

@chriskrycho
Copy link
Contributor

@NullVoxPopuli, the final unanswered question is answered unambiguously in the guides:

The "reason" for the error (i.e. the exception thrown or the promise reject value) will be passed to that error state as its model.

@josemarluedke
Copy link

I love the direction this is going!

I do like a lot the ability to import a component to the route to render. It does address some of my concerns from my #EmberJS2019 blog post.

For the loading and error states, I believe it would be useful to pass an argument to these components to clarify what context they have been rendered. For example, if I would like to render the same component for all the three states, it would be difficult to know if it is loading or not. If your model returns a promise, you would need to get a helper for seeing if it is pending or not. Maybe Ember could pass a flag to these components, something equivalent to this: @isLoading={{true}} and @isError={{true}}. Or @isRouteLoading={{true}} and @isRouteError={{true}}.

@chriskrycho
Copy link
Contributor

Even better would be to pass a tagged value, which is nicer for JS users, and much nicer from the perspective of TS users. Given these backing definitions (in TS, but they could equally just be "what we do" as long as it's well-defined):

enum State {
  Loading = "loading",
  Resolved = "resolved",
  Error = "error"
}

type Model<T, E> =
  | { state: State.Loading }
  | { state: State.Resolved, data: T }
  | { state: State.Error, error: E };

Assuming that whole item is passed as the model to the component, equivalent to this:

<SomeComponent @model={{model}} />

From the JS perspective, you'd be able to check it like this:

import Component from '@glimmer/component';

class SomeComponent extends Component {
  constructor(owner, args) {
    super(owner, args);
    switch (args.model.state) {
      case 'resolved':
        // normal handling...
        break;
      case 'loading':
        // loading handling...
        break;
      case 'error':
        // error handling...
        break;
      default:
        // can't actually get here
        break;
    }
  }
}

If we designed it to "spread" the arguments instead, as if this were the invocation:

<SomeComponent @state={{model.state}} @data={{model.data}} />

Then on the JS side, it'd be the same, except with one fewer layers of access: just args.state instead of args.model.state.

The upshot for TS is that you could write something like this (assuming the original invocation style):

import Component from '@glimmer/component';

class SomeComponent extends Component<Model<String, Error>> {
  constructor(owner, args) {
    super(owner, args);
    switch (args.model.state) {
      case State.Resolved:
        // We can't see the `error` value here; it's a type error to access it.
        transformData(args.model.data);
        break;
      case State.Loading:
        // We can't see the `error` or `data` values here; it's a type error to
        // access either.
        break;
      case State.Error:
        // We can't see the `data` value here; it's a type error to access it.
        transformErrorForDisplay(args.model.error);
        break;
      default:
        // special helper that throws a runtime error, but more importantly
        // *type-checks for exhaustiveness*.
        assertNever(args.model.state);
        break;
    }
  }
}

You can do similar with the booleans, but in general this pattern is much more robust and reliable in my experience—even when you're just using JS, because you can never accidentally end up with isLoading === true and isError === true this way.

@NullVoxPopuli
Copy link
Contributor Author

I just added some examples and incorporated @chriskrycho's idea with the route state type args and such :) <3

@michaelrkn
Copy link

Love where this is at!

When new routes are generated, I imagine we want to use this format. It would probably make sense to generate a component with the same name. Does that make sense? If so, should that be included under How We Teach This?

When a route is generated, what should be the default value for the loading and error components? Should an empty component for Loading and Error be included in new apps and the default values set to those?

@michaelrkn
Copy link

michaelrkn commented Jun 20, 2019

Also, does this really depend on #496 and #454? It seems like this is complementary, not dependent.

For that matter, the changes this RFC proposes have routes render components rather than controller-backed templates. I don’t think it should really depend on #380, as those concerns aren’t dependent. With this and #380 implemented, we no longer need controllers, but they can be worked on in parallel, not depending on each other.

@NullVoxPopuli
Copy link
Contributor Author

Also, does this really depend on #496 and #454? It seems like this is complementary, not dependent.

It's more of a conceptual requirement in order to make teaching easier.

we no longer need controllers, but they can be worked on in parallel, not depending on each other.

correct ;)

@michaelrkn michaelrkn mentioned this pull request Jul 1, 2019
@michaelrkn
Copy link

Are we missing anything here around the index and application routes? I assume they will continue working with controllers as-is, unless you generate the route explicitly.

Maybe then in a 4.0 release they switch behavior to use components in their "implicit" form? Or maybe they will need to be explicit? I think these concerns are for a future RFC, but I just want to make sure we aren't missing anything for this RFC.

@tleperou
Copy link

When new routes are generated, I imagine we want to use this format. It would probably make sense to generate a component with the same name.

That means for the route users generated via the cli; we get also a component users automatically generated explicitly (in the file structure) or implicitly (maybe during the run time)?

Love where this is at!

200% agree. I used to create a wrapper component to avoid using controllers and routers -when possible- for the application's business logic. I can't forward to using this RFC proposal 👯‍♀️

As @michaelrkn mentioned, the #380 dependency of the RFC may not be required for implementing it. However, is the proposal here really relevant without #380 ? Query parameters are the cornerstone of controllers 😅

@michaelrkn
Copy link

That means for the route users generated via the cli; we get also a component users automatically generated explicitly (in the file structure) or implicitly (maybe during the run time)?

I would suggest explicitly, in line with how much of Ember is moving now.

However, is the proposal here really relevant without #380 ? Query parameters are the cornerstone of controllers 😅

I think they are independent. You could have #380 and still have a route render a template, no component. And you could have this RFC, and have a route render a component, no query params. The two together cover all use cases, but neither blocks the other.

@NullVoxPopuli
Copy link
Contributor Author

They are technically independent, but as independent RFCs, they don't tell the story I want to tell. :-\

@michaelrkn
Copy link

I had another thought today, although I don't know if it's any good. One possible disadvantage to the approach we have here and in #510 is that there's still some hidden/implicit behavior. Here in #499, we have some static properties that define what loading, error, and render components to use, and we just have to know the framework calls them in order from somewhere. In #510, we have an object that's returned from a hook that we have to know the framework will pass into the component as arguments.

What if we took a much more explicit approach, like:

import hash from 'rsvp';
import ProfileComponent from './components/Profile';
import LoadingComponent from './components/Loading';
import ErrorComponent from './components/Error';

export default class ProfileRoute extends Route {
  @service store;
  onEnter({ profile_id }) {
    renderComponent(LoadingComponent);
    hash({
      profile: this.store.findRecord('profile', profile_id),
      comments: this.store.query('comments', { profile_id })
    }).then((data) => {
      renderComponent(ProfileComponent, data);
    }).catch((error) => {
      renderComponent(ErrorComponent, error);
    });
  }
}

Then it would be very obvious what gets called, at what point, and what data is getting passed in.

On the other hand, maybe this is overly verbose, and maybe it gives too much freedom for people to stray away from the happy path.

Just a thought!

@tleperou
Copy link

Does Route need hooks? If so, it would be great to explicitly implement them:

import { OnRender, OnLoading, OnError } from '@ember/routing/route';
export default class ProfileRoute extends Route implements OnRender, OnLoading, OnError {}

But this is currently impossible because Ember does not rely on TS, right? It looks like Angular, though.

@michaelrkn renderComponent sounds like the deprecated render and renderTemplate 🤔 .

What about getting, also, a function instead of a static field? In the case, we want to handle states differently:

import FooComponent, { LoadingComponent, ErrorComponent } from './components/Foo';
import Route from '@ember/routing/route';

export default class extends Route {
  @service router;
  @service remoteLogger;

  static render = FooComponent;
  static loading = LoadingComponent;
  error(error) {
    if(error instanceof ErrorAPI) {
      this.remoteLogger.log(error, new Date().getTime());
    }
    return ErrorComponent; // how to pass `error` to that component?
  }
}

@NullVoxPopuli
Copy link
Contributor Author

I Updated the description with the status of this RFC -- which links to something @pzuraq spit balled in Discord ;)

@NullVoxPopuli
Copy link
Contributor Author

Closing in favor of:

Thanks for moving the ball with setRouteComponent, @pzuraq <3 🎉

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.