-
Notifications
You must be signed in to change notification settings - Fork 603
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
Quest: Drop mixins #2185
Comments
I think understanding "how the mechanism works" is significantly more complicated for decorators than it is for the "mixin pattern", which is essentially just a factory function that produces a subclass of the passed in class 😅 |
Hm, maybe – I think the difference is decorators are (or will be) something that people know and is familiar to them while methods like the above might not be. |
function mixinFoo(ParentClass) {
return class extends ParentClass {
foo() { /* ... */ }
};
} I honestly don't understand what's so hard to understand about such a factory function 🤔 |
I personally feel like the class decorator approach would be easier to integrate than importing functions and then creating your own base-classes. At least in the first steps of a project. It feels significantly more "magical" than the Additional questions regarding this approach: Can people override methods mixed in that way? Can class-decorators be combined? The "custom mixin"-approach feels like a good middle way as it's pretty easy to understand what's happening, very explicit and doesn't come with any integration headaches for people that just want to get started and don't want to dive deeper into the things that ESA is doing internally. |
Yes, both are possible. |
I think option 3 could be valuable both in the short term as a bridging solution (giving the team more time to work on the design of a higher-level API) and in the long term as a set of primitives for those who need a more granular API. The uncertainty around decorators is a risk, for sure, but I have to say I love how it reads! There’s something pleasingly intention-revealing about the decorator form. |
I agree with @jgwhite: option 3 is great to have, because it can co-exist with other options focused on the class itself. To me, it's basically deciding between 1 and 2. |
A couple thoughts:
My very strong recommendation is to build option 3 and see what people's real world use of that primitive plays out as. It’s perfectly safe to then add option 2 as a convenience on top of it, itself implemented exactly in terms of those primitives. This is the ideal toward which Ember itself is working in most major API designs these days, and it’s a good one: if a user needs the control afforded by the lower-level primitive, for whatever reason including because they’re experimenting with new router ideas or similar, they can have it. And you may find that the little bit of “boilerplate” is in practice not that bad after all… or that it is and you ship option 2! But starting with the primitives gives you flexibility later and forward motion now. |
Why not support both a factory function, and class decorators? Under the hood they are basically the same. |
I thought about suggesting that! At this point I think that decision can be deferred, but also there are at least two good reasons not to:
And the other upside if shipping primitives first is that it lets users experiment with life without either as well as with solutions built on top of them without committing to that sugar as a public API. |
FWIW we already ship the primitives and the mixins are mostly just the glue code that connects those primitives to the built-in constructs in Ember. to be honest, I have doubts that extracting mid-level primitives between the already-shipped low-level primitives and the high-level mixins will actually result in any significant advantages. |
I understand everyone's reasons for preferring option 3. My only worry is that we would/might not be able to keep integrating ESA into an app as simple as it is now with that approach. It's just a difference in complexity and the amount/depth of concepts you have to understand when you have to invoke a particular method in a particular place etc. The simple integration mechanism has been the main focus of ESA from the beginning (and probably is the only thing about it that is still simple now). Another option of course could be to export the primitives and also export base classes that invoke the primitives in all the right places. I think the overhead in bundle size would be neglect-able. However, some of the above mixins could likely be replaced with other approaches altogether. For example, what the |
I took a look at what I think could be a good approach here. The mixins do not necessarily translate to function exports 1:1 and we can get rid of some concepts completely.
|
using initializers has developed into an anti-pattern. they make it significantly harder to mock things in tests for example and they can make rendering tests slower than necessary. |
Hm, what’s a good alternative for an adding to add some code to an app that auto-connects parts of the app in the way described above? Or is there no alternative? |
My take at this point is that auto-connecting things like that is not worth the trivial convenience it adds. It is convenient, but taking a very short time to wire up something in your application route just isn't that big of a deal and does make everything both easier to test and easier to understand for someone new to the code. |
In this case, setting it up in the Route constructor? |
Hm, I understand these points. The thing is we should not force people to copy/paste this into their application route: import { triggerAuthentication, sessionAuthenticated, sessionInvalidation, } from 'ember-simple-auth/routing';
export default class ApplicationRoute extends Route {
@service session;
constructor() {
this.session.on('authenticationSucceeded', () => this.sessionAuthenticated());
this.session.on('invalidationSucceeded', () => this.sessionInvalidated());
this.session.on('requireAuthentication', () => this.triggerAuthentication());
}
sessionAuthenticated() {
sessionAuthenticated();
}
sessionInvalidated() {
sessionInvalidated();
}
triggerAuthentication() {
triggerAuthentication();
}
} While this is arguably more explicit and less magical, it'd substantially increase the cognitive overhead when integrating ESA into a codebase. An alternative to this could be just providing one function to be called in the constructor that would effectively set up the above (without actually adding the methods to the class of course): import { setupAutoSessionRouting } from 'ember-simple-auth/routing';
export default class ApplicationRoute extends Route {
constructor() {
setupAutoSessionRouting();
}
} |
how do you handle cleanup of these events then? |
I assume |
This is the fundamental point of disagreement about the design here. I think one-time extra cognitive load during setup more than pays for itself during the long lives of applications; more than that, I actually think it's helpful in teaching people what they addon is actually doing when they set it up. I won't belabor the point further though, as I think I've made my perspective clear and I don't want to be that guy who's just arguing. 😉 |
Indeed we disagree here :) I think my short response is that if you're supposed to understand everything you're using you cannot actually do anything because there's no way you can ever live up to that expectation – I strongly believe that hiding details is one of ESA's strong points that many people appreciate and that only enabled its relatively wide adoption. Also: is That said though, there's a difference between hiding details and magically adding or changing stuff in an app. With the mixin approach things were actually not so magical since we relied on a (more or less) well-understood mechanism in Ember. The fact that export default Route.extend(ApplicationRouteMixin); adds methods to the route isn't surprising as that's what mixins do. I agree that export default class ApplicationRoute extends Route {
constructor() {
setupAutoSessionRouting();
}
} adding kind of optional methods to the application route is not necessarily straight forward. So maybe there's a better solution. Maybe we could have something that would essentially do this: import { triggerAuthentication, sessionAuthenticated, sessionInvalidation, } from 'ember-simple-auth/routing';
session.on('authenticationSucceeded', () => sessionAuthenticated());
session.on('invalidationSucceeded', () => sessionInvalidated());
session.on('requireAuthentication', () => triggerAuthentication()); This would not add any of the event handlers to the application route or invoke methods on the application route if present. It would simply apply ESA's default handling of these events. If we don't want to use an initializer we could have something to add to the router or something: import EmberRouter from '@ember/routing/router';
import ESARouter from 'ember-simple-auth/routing';
const Router = EmberRouter.extend);
Router.map(function() {
…
});
export default ESARouter(Router); If anyone wants to customize any of the default behavior, the way to do that would be the above and you'd have to copy/paste code into the application route and set up the event handlers yourself. In general, my main goal would be to find an API that hides all of the details from anyone who isn't interested while at the same time not leaning heavily on things being added/changed magically but providing some level of customizability.
Don't worry – the input is highly appreciated! |
@marcoow Could you elaborate why we would need the route hooks registered at all of they just contain default implementation? Consumer could still implement a custom function if needed. But until then directly binding event to default event handler should be fine. It reduces complexity and lines of code needed for integration. It would be still obvious how to replace or extend a default implementation. |
Yes, that's basically what I'm suggesting here. We'd want an easy way to switch on the default implementation which could be this (for example): import EmberRouter from '@ember/routing/router';
import ESARouter from 'ember-simple-auth/routing';
const Router = EmberRouter.extend);
Router.map(function() {
…
});
export default ESARouter(Router); An alternative would be an instance initializer but if that has downsides it's likely not a great alternative in particular as we're looking for a sustainable solution. We'd also want a straight forward way to extend or customize the default behaviour which you'd usually want at the routing layer as it involves redirecting. That could be this: import { triggerAuthentication, sessionAuthenticated, sessionInvalidation, } from 'ember-simple-auth/routing';
export default class ApplicationRoute extends Route {
@service session;
constructor() {
this.session.on('authenticationSucceeded', () => this.sessionAuthenticated());
this.session.on('invalidationSucceeded', () => this.sessionInvalidated());
this.session.on('requireAuthentication', () => this.triggerAuthentication());
}
sessionAuthenticated() {
// custom behavior goes here potentially plus invoking the default behavior:
sessionAuthenticated();
}
sessionInvalidated() {
// custom behavior goes here potentially plus invoking the default behavior:
sessionInvalidated();
}
triggerAuthentication() {
// custom behavior goes here potentially plus invoking the default behavior:
triggerAuthentication();
}
} The reason why I suggest exporting the default behaviour here as well is so you do not need to copy that code (which might change over time of course so that you'd have to adapt the copied code) but can just implement custom logic in addition to also invoking the default behaviour. |
Also to be clear: opting-in to the default behaviour via the router is arbitrary really – it could be using any mechanism that allows us to get a hold of an owner. |
A special router, custom mixin, factory function and decorator is too much magic in my opinion. I think it's fine to ask the user to register three event handlers in application route (and remove them on destroy). It's not much more lines of code but directly teaches the building blocks and provides an obvious way how to customize. It would be still simple without being magic. |
To be clear – nobody is suggesting a special router nor all of the above together. Decorators are mostly off the table for now as well. The easiest way would be to use an instance initializer to hook up the default functionality but as that doesn't seem to be a sustainable solution we'll have to look for alternatives. |
The alternative I'm arguing for would be like this: import { triggerAuthentication, sessionAuthenticated, sessionInvalidation, } from 'ember-simple-auth/routing';
export default class ApplicationRoute extends Route {
@service session;
constructor() {
this.session.on('authenticationSucceeded', () => sessionAuthenticated);
this.session.on('invalidationSucceeded', () => sessionInvalidated);
}
willDestory() {
this.session.off('authenticationSucceeded', () => sessionAuthenticated);
this.session.off('invalidationSucceeded', () => sessionInvalidated);
}
} I don't see any need for an additional layer of high-level API as this is already very simple integration. I see this similar for other functionalities for ember-simple-auth. E.g. relying on the primitives exposed by session service might also be enough for authentication and unauthenticated route functionalities: export default class ApplicationRoute extends Route {
@service session;
async beforeModel() {
try {
await this.session.requireAuthentication();
catch (error) {
// user is not authenticated
return this.transitionTo('login');
}
}
} |
It is relatively simple but I'd rather look for a way that is even simpler since the flexibility this provides is not needed by a large part of apps that just want to stick with the default implementation. Thus there is no real benefit forcing everyone to write this glue code themselves. Manually subscribing/unsubscribing to events should certainly be possible for everyone who wants it but I strongly believe there is value in not forcing a bunch glue code onto every user of the addon. |
I still think that a bunch of factory functions that do the modifications for you would be the easiest path forward. The factories must only use public API and basically represent the guide on how to apply the smaller building blocks on their own by applying them automatically. If you are happy with the defaults, use It would be nice to have primitives for binding/removing the event listeners and so on. |
Let me clarify a point here:
This isn't what I meant, and I don't think it's a reasonable characterization of my position, but I understand how what I wrote read this way. What I'm actually saying above is that the "boilerplate" here just teaches you what you're changing. It doesn't teach you all the mechanics or require you to understand them; it does teach you that you're modifying the behavior of various routes. If you later want (or need!) to understand all the mechanics, you have an obvious thread to tug on. Folks may or may not find that persuasive—our disagreement appears to be deep-seated and intractable, and that just is what it is; taste does vary! I just wanted to clarify what I was and was not saying above. 😅 |
Sorry, didn't mean to mis-characterize your position. The input is greatly appreciated and I do understand where you're coming from and what you're saying. I just think ease of adoption is one of ESA's core strengths and we should all aim for a solution that
Right now integrating ESA requires mixing in 2 or at most 3 mixins in some routes which I think checks all the above boxes – although comes with the downside of mixins of course. |
I think the best option would be to expose all functionality via the
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default Route.extend({
session: service(),
beforeModel(transition) {
this.session.requireAuthentication(transition, 'login');
},
model() {
return this.get('store').findAll('post');
}
});
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default Route.extend({
session: service(),
beforeModel(transition) {
this.session.prohibitAuthentication(transition, 'index');
},
});
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
export default Route.extend({
session: service(),
sessionAccount: service('session-account'),
init() {
this._super(...arguments);
this.session.on('authenticationSucceeded', () => this.sessionAuthenticated());
this.session.on('invalidationSucceeded', () => this.session.handleInvalidation());
this.session.on('authenticationRequested', () => this.session.triggerAuthentication('login'));
},
beforeModel() {
return this._loadCurrentUser();
},
sessionAuthenticated() {
this.session.handleAuthentication('index');
this._loadCurrentUser();
},
_loadCurrentUser() {
return this.get('sessionAccount').loadCurrentUser().catch(() => this.get('session').invalidate());
}
}); In this example, the app adds custom logic to the handler for the this.session.on('authenticationSucceeded', () => this.session.handleAuthentication('index'));
this.session.on('invalidationSucceeded', () => this.session.handleInvalidation('index'));
this.session.on('authenticationRequested', () => this.session.triggerAuthentication('login')); |
I like that proposal. Maybe even the // app/services/session.js
import BaseService from 'ember-simple-auth/services/session';
import { inject as service } from '@ember/service';
export default class SessionService extends BaseService {
@service sessionAccount;
sessionAuthenticated() {
// call super to have default implementation
super.sessionAuthenticated();
// do some additional custom stuff
this.get('sessionAccount').loadCurrentUser().catch(() => this.get('session').invalidate());
}
} Extending a service is not a very common way in ember to customize an addon but it would be similar to how adapters and serializers are customized. Similar to mixins it has the risk of name clashes but this could be reduced if the base service only exposes it's public API on that service and hides it's internal state and methods. |
Yes, extending the service would work as well. In the above example, attaching the event handlers in the application route is kind of arbitrary and could be done elsewhere as well. Note: All of this being wired up in the routes is mostly for historical reasons since there was no router service in earlier versions of Ember and you needed to have something on the route be called when the session's state changed so you could transition accordingly and the routes were really the only place where that was possible. In fact, the events on the session service could be dropped altogether since they are no longer needed when extending the session service (and could easily be added in again in an extension of the session service) – see f03483a (I don't know why the tests are failing 😞 ) |
There's a WIP PR now: #2198 |
status : logged in. The datasets and blocks are requested, received, pushed to store. Displayed : route menu links and left-panel nav tabs. package.json : upgrade most packages, via npm outdated, npm-check-updates and individually / manual. Was aiming for just Ember4 / Bootstrap4, but updating to current time is an easy way to find a common baseline. select a fork of ember-parachute which has replaced tryInvoke() : @voll/ (or DazzlingFugu). Dropped some packages which are no longer required : bootstrap-sass ember-scroll-to ember-radio-button ember-component-inbound-actions ember-cli-document-title ember-cli-jshint store.js : change to export ember-data/store (deprecation id: ember-data:deprecate-legacy-imports), app.scss : drop @import ember-bootstrap/bootstrap-sprockets, may be required but not found via this path. configuration.js : getSiteOrigin() : drop .concreteImplementation which is now not needed or correct in accessing .location. add d3-initialise-global.js; this includes elements added in 83655c7 in draw-map.js, commented-out here in favour of simply including all of d3. divgrid.js : set as global window.d3_divgrid and d3.divgrid swap-in replacement for (sunsetted) ember-simple-auth mixins; later these can be merged into the routes instead of using them as mixins. add ember-simple-auth-mixin-replacements/{{application,{,un}authenticated}-route,data-adapter}-mixin.js based on mainmatter/ember-simple-auth#2185 (comment), with change from Route -> Mixin. environment.js : locationType: auto -> history (deprecation id: deprecate-auto-location) optional-features.json : jquery-integration: true -> false ember-cli-build.js : bootstrapVersion: 3 -> 4, importBootstrapCSS': true add webpack: { stats : { errorDetails: true }} for debugging the build. drop import of bower_components remove .bowerrc and bower.json (will also remove references to bower in e.g. Dockerfile, .gitignore, scripts and documentation). .ember-cli : disableAnalytics: false -> true bootstrapJs : append dist/ to bootstrap/js/ mapview.js : use the @voll/ fork of ember-parachute. deprecationIds : add ember-data:deprecate-legacy-imports, ember-polyfills.deprecate-assign. prefix attributes with this. in .hbs models/dataset.js, block.js : pass param options {async, inverse} to belongsTo,hasMany() - these options are documented as optional, but assert()s in those functions require them. If these are required they will also have to be added to other models. routes/application.js : merge in required changes in ApplicationRouteMixin instead of mixing it in : init(), beforeModel() : session.setup(); and sessionAuthenticated() : handleAuthentication(index). d3.schemeCategory20 -> 10 (later will select from the additional schemes available in d3 v4). Change {{#link-to }} to angle brackets <LinkTo >, add @ and names to params, selected from --run-codemods output. auth-input.hbs : use @autocomplete add app/transforms/{boolean.js,date.js,string.js as per deprecation id: ember-data:deprecate-legacy-imports. for ease of debugging 2 1-file packages have been copied in, these files are the plantinformatics/ fork plus some edits : ember-cli-multi-store-service/addon/services/multi-store.js -> app/services/ : replace use of global Ember via import : Service, getOwner, A as Ember_A, get as Ember_get, set as Ember_set. ember-split-view-modifier/addon/modifiers/split-view.js -> app/modifiers/ : 3.13 -> 3.22, createModifier() : param factory -> Definition, factory.create() -> new Definition()
ember-simple-auth has been heavily leveraging mixins form the beginning. Using mixins is what allowed ESA to be integrated easily be developers into their applications since the only thing that they'd need to do was to mix-in these mixins into some of the routes of the application and ESA would just work. However, with native JavaScript classes, mixins aren't really well supported and you have to fall back to constructs like
which mixes native classes and Ember's old object model and thus results in unintuitive and harder-to-understand-than-necessary code. Also, eventually mixins will likely be removed from Ember altogether and they already trigger linter warnings which means installing ESA into an app will trigger new linter warnings which isn't a great and particularly reassuring experience obviously.
For those reasons, ESA should drop its mixins and adopt a new approach. There are a few key attributes we're looking for in a new solution:
Status Quo
ESA currently provides 5 mixins:
ApplicationRouteMixin
: This is an optional mixin that when used, maps certain events of the session to methods on the application route (by overridinginit
) that it provides default implementations of.AuthenticatedRouteMixin
: This is mixed in to route classes that are to expect the session to be authenticated in order to be accessed. The mixin adds a custombeforeModel
method that will call the original one (if present) if the session is authenticated or calltriggerAuthentication
otherwise that it provides a default implementation of which redirects to a configurable login routeUnauthenticatedRouteMixin
: This is the opposite of theAuthenticatedRouteMixin
and works in the same way by adding a default implementation ofbeforeModel
that checks whether the session is not authenticated and redirects otherwise (without a dedicated method for that)OAuth2ImplicitGrantCallbackRouteMixin
is used in combination with theOAuth2ImplicitGrantAuthenticator
and triggers authentication of the session via that authenticator when the route is visited (by overridingactivate
)DataAdapterMixin
is used with Ember Data adapters and overrides thehandleResponse
method so that it automatically invalidates the session on 401 error responsesOptions
There are several options that might be worth looking into more deeply:
The advantage of decorators is they would still allow integrating ESA super easily but the disadvantage is the uncertainty around the decorators proposal and the fact that we might not be able to do everything we need to do with the final version of the proposal
More information on the approach can be found here. The disadvantage of this is that it is fairly exotic and while technically ESA could still be adopted in a way that is very similar to the current mixins-based mechanism, it would result in substantially increased complexity as it'd be unclear to many devs what they are doing exactly and how the mechanism works.
While this has the advantage of being fairly simple and straight-forward, the disadvantage is that it requires the dev to follow more steps when adapting ESA in an application which leads to more complexity simply as the amount of glue code that integrates ESA and the application is higer.
Prior Art
@mike-north did some work some time ago to split the ESA codebase apart into exports of individual methods that could be imported and called directly as a replacement for mixins. Unfortunately nobody ever found the time to implement it and the effort didn't lead anywhere…
The text was updated successfully, but these errors were encountered: