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

WIP: Pulls/4/react dependency injection #70

Merged

Conversation

unclecheese
Copy link

@unclecheese unclecheese commented May 16, 2017

Don't merge. API in active flux and discussion.

@unclecheese
Copy link
Author

unclecheese commented May 16, 2017

Issues, limitations, and open questions

Based on my observations so far...

Only one component gets to influence render()

While the props shape can represent the aggregate influence of all customisations of the component, render() doesn't quite work that way. See experiment 2 and experiment 3 for how this becomes a problem. If the password strength checker component wanted to render the alert message as UI, it won't be able to rely on the core TextField UI. That UI may or may not have already been updated by TextFieldCharacterCountCreator.

Order of execution / priority

Everything in the DI layer will suffer from this problem, but it's something to keep in mind. Should we keep this to a simple priority parameter to customise()? That might invite an arms race between module developers, e.g. {priority: 999999} etc..

The need for context

Right now, all customisations are global. Every single FormBuilder rendered text field in the CMS now has a character count. This is not good enough. We need an API that allows the both the consumer of the injected component to specify context, as well as the provider of the injected component. Rough API:

this.context.injector.get('TextField.CampaignAdmin.EditForm.Title');

Injector.customise('TextField.CampaignAdmin.EditForm.Title', NewComponentCreator);

Ideally, we could make this more flexible with wildcarding:

Injector.customise('TextField.CampaignAdmin.*') // All text fields in CampaignAdmin

Injector.customise('TextField.AssetAdmin.SearchForm.*') // All text fields in AssetAdmin search

Injector.customise('TextField.*.EditForm.*') // All text fields in edit forms in any CMS section

Injector.customise('DropdownField.*') // I don't like Chosen.

propTypes collisions

If I want the title of my GalleryItem to be an inline image, I'm hosed right now, as the title propType is declared upstream as string. We need an API for influencing that and resolving conflicts. This becomes a bit more problematic with things like oneOfType() and shape().

We should throw when register() is called more than once for the same service

register() is used to declare the service for the first time, and defines the service that will be fed to the front of the middleware chain (customise()). It is conceivable that you would want something so specific that you just want to override the entire service. This would conceivably nuke all the existing middleware definitions, or at least put them at risk of failure. Possible solution:

Injector.register('TextField', TextField); // OK

Injector.register('TextField', AnotherTextField); // Throws

Injector.register('TextField', AnotherTextField, { force: true }); // OK

Components are far too monolithic to make DI worthwhile

Without the opportunity to inject on small components, e.g. <GalleryItemTitle />, we're inviting injection by subclass, which we really want to discourage.

Future story: Break up large components into smaller, more agnostic pieces.

silverstripe-admin must be authoritative

The admin module has last rights to the injector, and gets to freeze it before boot. A simple way to achieve that is to put silverstripe-admin's injector calls in a document.load event. However, we cannot enforce in any realistic way that other modules don't do that and create some unpredictable race condition.

One possible solution, as noted below is to wrap the admin module's injector calls in document.load and setTimeout(... 0,).

Copy link
Contributor

@flamerohr flamerohr left a comment

Choose a reason for hiding this comment

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

The concept is sound, I'll be giving this a test run later and see if I can get this going in parallel to what you've done :)

@@ -8,9 +9,10 @@ import SilverStripeComponent from 'lib/SilverStripeComponent';
class App extends SilverStripeComponent {
render() {
// TODO re-add <div className="app"> wrapper when applying to document.body
const Child = React.Children.only(this.props.children);
return (Child);
return <div>{this.props.children}</div>;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm a little cautious of this due to the use of flexbox currently for the App component.

But I know you're aware of it, so I'll leave that to after this pull request is ready :D

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, I haven't seen any issues yet, but I have noted it as a temporary hack that needs to be reviewed before merge.

Injector.register('LabelField', LabelField);
Injector.register('TreeDropdownField', TreeDropdownField);

Injector.freeze();
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to freeze registering more?

asset-admin injects a few more fields as well as potentially other modules.
Or perhaps only freeze protect customize

Copy link
Author

Choose a reason for hiding this comment

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

So the idea here is that we would discourage every module but silverstripe-admin from making Injector mutations on document load. Admin is the only module that gets that privilege, ensuring it comes last. The other way we could do it, if we wanted to be a bit more permissive is to do it on document load, wrapped in a setTimeout(.., 0); to force it to a new thread.

Neither of those are ideal. We need some kind of contract between modules to ensure silverstripe-admin comes last, though.

Component,
Object.assign({}, this.props, props),
this.props.children
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is React.createElement() preferred over <Component {...this.props} />?

Copy link
Author

Choose a reason for hiding this comment

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

Either is fine. Sometimes I find React.createElement more expressive, but we should be consistent and use JSX.

@flamerohr
Copy link
Contributor

flamerohr commented May 17, 2017

@unclecheese I'm curious what went wrong with both experiments?
experiment 3 seems like all sorts of no with the setTimeout() and alert() lol

Context would be a tricky one, even more so if you throw in wildcards. Most forms are named specifically and don't know their own context except that it could be inferred by the field's id property.

PropTypes could be opened up to be slightly more forgiving, e.g. display types like title could be handled by the castStringToElement() API that we have, which casts strings to <Element>{string}</Element> or if it's contained in an object it will be casted as such:
{ react: ReactComponent } as a react component
{ html: HtmlString } as unescaped html
I forget the other allowed types, but this could be used a bit more to our advantage.
I wouldn't recommend some data PropTypes to be changed though, such as count properties.

I agree with the throwing of an already registered service, I don't agree with a force flag to allow ourselves or modules to override it as such and would be more keen to opt for a customise() call to handle overriding it (this is assuming that priority has been solved somehow first)

Totally agree with breaking up these monolithic components to smaller ones, I'm also keen to start moving presentational components to stateless functional components and move away from es6 classes for them.
If possible, when breaking up these components, work on opening up the PropTypes as mentioned above to be more forgiving when display certain types of data.

Another note about priority, what do you thinking about having a "name" param for the customize() API, which can help identify the customisations a bit more succinctly, the idea is from the way SS handle config.yml files.

* @return object|null
*/
getComponentForDataType(dataType) {
const { injector: { get } } = this.context;
Copy link
Member

Choose a reason for hiding this comment

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

Yay, great way to solve this (withInjector context HoC)

const register = (key, value) => {
di.factory(
key,
() => compose(...(middlewares[key] || []))(value)
Copy link
Member

Choose a reason for hiding this comment

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

Such ES6, so compose! ❤️

Copy link
Author

Choose a reason for hiding this comment

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

:) I got a bit cheeky here.

@unclecheese
Copy link
Author

unclecheese commented May 17, 2017

I'm curious what went wrong with both experiments?
experiment 3 seems like all sorts of no with the setTimeout() and alert() lol

Yeah, it's not meant to be anything you'd use in the real world. Just a proof of concept. setTimeout() is used to throttle the onChange event (so it doesn't trigger every keystroke), and the use of alert() was alluded to in the issue I described with render(). Only one customisation really gets to influence the UI. My password checker could render that message as UI (e.g. an error message below the field), but I have no guarantee that TextField at that point is the standard core TextField. It could have been (and is, in that experiment) already customised to render new UI. So the only easy way to render that messaging is with an alert(). Again, just proving, in theory, we can do it, at this phase.

I agree with the throwing of an already registered service, I don't agree with a force flag to allow ourselves or modules to override it as such and would be more keen to opt for a customise() call to handle overriding it (this is assuming that priority has been solved somehow first)

I tend to agree. The only case I see for having a force parameter is that it's consistent with the Injector pattern we have in the backend, which is "Whomever gets there last gets to have their implementation." If five modules inject on Mailer, four of those are completely irrelevant to the execution chain. It's not unrealistic to expect that frontend devs might want the same authority.

Another note about priority, what do you thinking about having a "name" param for the customize() API, which can help identify the customisations a bit more succinctly, the idea is from the way SS handle config.yml files.

Love this idea. We could use it on the displayName property to make the React console a lot more informative, as well.

@unclecheese
Copy link
Author

I've added the note about ensuring silverstripe-admin authority to the OP.

`);
return;
}
const resolved = dependencies.map(this.context.injector.get);
Copy link
Member

Choose a reason for hiding this comment

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

So the constructed container could declare dependencies as propTypes, and this HoC resolves them automatically. Does that mean most container components and some presentational components would be wrapped this way in their default export? I assume that doesn't interfere with the ability to further wrap these exported components with customise()?

Copy link
Author

Choose a reason for hiding this comment

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

Yes. The idea is that almost everything should be wrapped in withInjector(), particularly containers, as they by definition have dependencies. Components that shouldn't would be simple presentational components that render only HTML. e.g. const Title = (props) => <h2>{props.children}</h2>;

There's nothing to my knowledge that interferes with an injector-consuming component being customised.

@chillu
Copy link
Member

chillu commented May 17, 2017

If I want the title of my GalleryItem to be an inline image, I'm hosed right now, as the title propType is declared upstream as string. We need an API for influencing that and resolving conflicts. This becomes a bit more problematic with things like oneOfType() and shape().

I think part of the answer here is making components more granular (such as a GalleryItemTitle), and allowing to replace them completely (or HoC wrap them). PropTypes are our API, and the typing should be specific enough to ensure the component can operate with the input variations. If it expects title as a string, that's because it's render() function will expect a string. If you want it to be a component, that's changing some fairly fundamental assumptions of the render() function (and potentially other logic in the component). I'm not a fan of Chris' suggestion on putting casting info in the propType shape (if I understand this correctly).

Order of execution / priority

Could we just sketch this out for now, and leave implementation to a follow-up card? I'd be keen to get clarity if we go down priority vs before/after as part of this cart, and making sure that we don't paint ourselves in a corner. If the consensus is before/after, then we need a JS constraint resolver library, which sounds like scope creep for this card.

The need for context

Service names in DI container are usually just strings. In SS4, we are using suffixes as well (<interface-fqcn>.<arbitrary-suffix>). So this would be a consistent approach - just that we need multiple layers of suffixes here. Wildcarding makes the system a bit more complex, but as long as we don't use regexes or something it feels like this is an appropriate level of flexibility. In particular for forms, we need context specific behaviour based on various criteria. My only worry is that it'll become quite opaque which implementation is chosen - can you think about diagnostics and debugging for this please?

FormBuilder and FormBuilderLoader don't know anything about the current section name, they only care about the schema URL. I guess the schema could contain this metadata? Ideally we keep section names consistent with their FQCN reference as well, which would look ugly in service name references. Outside of form builder, would you see e.g. <GalleryTile> knowing about section names? I want to avoid components knowing too much. Outside of FormBuilder, is it important to know in which section context a component is applied?

@flamerohr
Copy link
Contributor

I'm not a fan of Chris' suggestion on putting casting info in the propType shape (if I understand this correctly).

Not quite how it works, for reference it's done with fieldHolder's title fields (leftTitle, rightTitle, Description) and allows the PHP to define html (or HOC to define react) for these fields if required rather than string. It does mean the propType would need to open up and allow for it though, yes...
I think it might be worth creating our own "displayText" prop type, so it can be reused, but that's outside the scope of this story :D

Copy link
Contributor

@flamerohr flamerohr left a comment

Choose a reason for hiding this comment

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

Overall, I think the theory of how this will work is sound and seeing it in the small testcases that you have provides some good foundation to our understanding.
Great for a POC.

A few linting issues, with attention to the fact that our current eslint ruleset doesn't lint object spread properly yet :(

*/
const addDisplayName = (name) => (Component) => {
const currentName = (Component.displayName || Component.name || 'Component');
Component.displayName = `${name}(${currentName})`;
Copy link
Contributor

Choose a reason for hiding this comment

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

eslint wouldn't allow modifying param properties

if this is necessary, perhaps add the // eslint-disable-next-line rule-here comment above this line

addDisplayName(meta.name),
factory
);
middlewares[key].push({...meta, factory});
Copy link
Contributor

Choose a reason for hiding this comment

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

currently our eslint rules do not support object spread :( (and it would need space padding { ...meta, factory })

* @param key The name of the dependency to customise
* @param factory The function that will compose the dependency. Gets passed the previous state of composition
*/
const customise = (meta, key, factory) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

meta is missing a docblock description

*/
const load = function load() {
for(const key in middlewares) {
if(middlewares.hasOwnProperty(key)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

how about using Object.keys(middlewares).forEach(key => {?
I'd normally reserve for...in and for...of for cases where breaking is beneficial.

withInjector() passed an argument for dependencies that is ${typeof dependencies}.
Must be an array of named dependencies.
`);
return;
Copy link
Contributor

Choose a reason for hiding this comment

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

this return is unreachable.
it's also an inconsistent return value it should be return null; if it were still around ;)

}
}

Component.contextTypes = injectorContext;
Copy link
Contributor

Choose a reason for hiding this comment

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

I have a feeling you may be overwriting other existing contextTypes for the injected Component, would be safer to have Component.contextTypes = Object.assign({}, Component.contextTypes, injectorContext);

but also, this will fail ESLint. As above add the // eslint-disable-next-line rule-here comment

* e.x.
* Injector.update(
* {
* name: 'my-module',
Copy link
Member

Choose a reason for hiding this comment

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

I think this should be called module rather than name, with beforeModule and afterModule counterparts. It's a bit confusing to use name here, I first thought it was the component name.

Copy link
Author

Choose a reason for hiding this comment

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

Per our discussion on Slack, name is now the first parameter of Injector.transform().

meta[k] !== undefined &&
(typeof meta[k] !== 'string' && !Array.isArray(meta[k]))
) {
throw new Error(`Injector.update() key ${k} must be a string or array`);
Copy link
Member

Choose a reason for hiding this comment

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

Thanks for doing these descriptive error messages, that really helps!

const graph = [];
let sortedMiddlewares = [];

middlewareList.forEach(entry => {
Copy link
Member

Choose a reason for hiding this comment

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

So if we apply a HoC in silverstripe-admin/ on a "base component" (can't think of a use case for this at the moment...), does the * allow modules to apply their customisations before?

Copy link
Author

Choose a reason for hiding this comment

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

You can't do anything in front of the base component. It's stored outside of the middleware chain completely. before: '*' will be executed as the first customisation of the base component but not before.

Copy link
Member

Choose a reason for hiding this comment

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

module-a/Component.js
module-a/HoC-a.js (applies to Component)
module-b/HoC-b.js (applies to Component)

Can I get HoC-b to apply before HoC-a?

Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming you're using injector for both cases, you'd put {before: 'HOC-a-group-name'} :)

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, that would be

Injector.transform('module-b', update => {
  update('Component', HOC);
}, { before: 'module-a' });

ContainerAPI.__reset__ = reset;
}

export default ContainerAPI;
Copy link
Member

Choose a reason for hiding this comment

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

In AirBnB standards, ES6 module exports should match the file name.

return InjectorProvider;
}

export {
Copy link
Member

Choose a reason for hiding this comment

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

Why don't you make this the default export?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah these should be default exports. They're named exports in the main Injector file, and i think this is just legacy.

return Injectable;
}

export {
Copy link
Member

Choose a reason for hiding this comment

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

Same here, should this be the default export?

* name: 'my-module',
* before: ['another-module']
* },
* wrap => {
Copy link
Member

Choose a reason for hiding this comment

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

That's a pretty weird syntax - particularly if you present it to PHP developers who might misread this as an array (like I just did). It's also mixing terminologies between "update", "callback" and "wrap".

I've looked at this for a few minutes, and have to honestly say that I can't figure out what gets executed when and how without actually stepping through a debugger. Can we make the API surface of this simpler?

Copy link
Member

Choose a reason for hiding this comment

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

We ended up with this syntax:

// module-a.js
Injector.transform('module-a.fields', update => {
    update('TextField', ComponentA);
    update('DateField', ComponentB);
});
Injector.transform('module-a.core', update => {
    update('Menu', ComponentC);
}, {beforeModule: '*'});


// module-b.js
Injector.transform('module-b', update => {
    update('TextField', ComponentD);
}, {beforeModule: 'module-a'});

Injector.__reset__();
});

const expectError = (func) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

There's a expect(() => <your test>).toThrow(); built in assert from jest :)
I'd prefer using what's out of the box if possible, I had to fix a fair number of these custom expects when I upgraded jest last time.

Injector.register('TestService', TestService);
expectError(() => Injector.get('TestService'));
Injector.load();
expect(Injector.get('TestService')()).toBe('test service');
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd suggest expect(typeof Injector.get('TestService')).toBe('function'); before this as well

* @param object
*/
const validateMeta = (meta) => {
if (typeof meta.name === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

quotes around undefined

}
PRIORITIES.forEach(k => {
if (
meta[k] !== undefined &&
Copy link
Contributor

Choose a reason for hiding this comment

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

might be a preference of mine... I'd typeof check for explicit undefined values

@unclecheese unclecheese force-pushed the pulls/4/react-dependency-injection branch from 6583088 to 00eaaac Compare May 25, 2017 00:18
@chillu
Copy link
Member

chillu commented May 25, 2017

@unclecheese build is failing on NPM_BUILD:

modulesModuleNotFoundError: Module not found: Error: Cannot resolve 'file' or 'directory' ./dependency-injection/Container in /home/travis/build/silverstripe/silverstripe-admin/client/src/lib

@unclecheese unclecheese force-pushed the pulls/4/react-dependency-injection branch from 521f73e to 3155e8c Compare May 25, 2017 22:07
Injected.contextTypes = injectorContext;
Injected.displayName = `inject(
${(Component.displayName || Component.name || 'Component')}
)`;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is really nice to have as a default

@unclecheese unclecheese merged commit df72497 into silverstripe:master May 26, 2017
@unclecheese unclecheese deleted the pulls/4/react-dependency-injection branch May 26, 2017 00:07
@chillu
Copy link
Member

chillu commented Jun 26, 2017

This caused a regression which was fixed with #116

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.

3 participants