-
Notifications
You must be signed in to change notification settings - Fork 92
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
WIP: Pulls/4/react dependency injection #70
Conversation
9f76ce1
to
f38a60c
Compare
Issues, limitations, and open questionsBased on my observations so far... Only one component gets to influence
|
There was a problem hiding this 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>; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 | ||
); |
There was a problem hiding this comment.
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} />
?
There was a problem hiding this comment.
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.
@unclecheese I'm curious what went wrong with both experiments? 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 PropTypes could be opened up to be slightly more forgiving, e.g. display types like 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 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. Another note about priority, what do you thinking about having a "name" param for the |
* @return object|null | ||
*/ | ||
getComponentForDataType(dataType) { | ||
const { injector: { get } } = this.context; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Such ES6, so compose! ❤️
There was a problem hiding this comment.
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.
Yeah, it's not meant to be anything you'd use in the real world. Just a proof of concept.
I tend to agree. The only case I see for having a
Love this idea. We could use it on the |
I've added the note about ensuring |
`); | ||
return; | ||
} | ||
const resolved = dependencies.map(this.context.injector.get); |
There was a problem hiding this comment.
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()
?
There was a problem hiding this comment.
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.
I think part of the answer here is making components more granular (such as a
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
Service names in DI container are usually just strings. In SS4, we are using suffixes as well (
|
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... |
There was a problem hiding this 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})`; |
There was a problem hiding this comment.
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}); |
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
4cce3bc
to
8367d91
Compare
* e.x. | ||
* Injector.update( | ||
* { | ||
* name: 'my-module', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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`); |
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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'}
:)
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 && |
There was a problem hiding this comment.
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
6583088
to
00eaaac
Compare
@unclecheese build is failing on NPM_BUILD:
|
521f73e
to
3155e8c
Compare
Injected.contextTypes = injectorContext; | ||
Injected.displayName = `inject( | ||
${(Component.displayName || Component.name || 'Component')} | ||
)`; |
There was a problem hiding this comment.
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
This caused a regression which was fixed with #116 |
Don't merge. API in active flux and discussion.