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

Added getContext/setContext effects, where context is acting like dyn… #735

Merged
merged 1 commit into from
Apr 24, 2017

Conversation

Andarist
Copy link
Member

@Andarist Andarist commented Dec 31, 2016

…amic scoping between parent/child tasks (resolves #713)

Just a quick draft of this proposed API. I've put several TODO:s in the code, they act as thought-placeholders and I would appreciate commenting each one of them.

Also there I've spotted a difference between standard middleware API and runSaga.

  • middleware - options are passed once, so encapsulated by the factory) and each subsequent middleware.run(saga) will receive them (also context now)
  • runSaga - each call expects the options all over again, so subsequent calls do not share context and it cannot be shared among 'top sagas' (at least not without creating a topContext within runSaga module)

cc @aikoven

@Andarist
Copy link
Member Author

Andarist commented Jan 1, 2017

also I think like some way for removing the context should be available as for now it wouldnt be possible to 'uncover' higher scope

function* saga() {
	yield setContext({ a: 1 })
	yield fork({
	  yield setContext({ a: 2 })
	  yield setContext({ a: undefined })
	  yield getContext('a') // undefined, no way to reach { a: 1 } at this point
	})
}

hating to put 3 effects for this rather simple feature - getContext, setContext and unsetContext (?)

@aikoven
Copy link
Contributor

aikoven commented Jan 1, 2017

First of all, thanks for spending time on this.

Some thoughts:

  1. Should generator have access to context properties set by itself? IMO it shouldn't, because you can just use variables for this. So maybe setContext should only have effect on children? Maybe even rename setContext with setChildContext (I don't really like the verbosity though)?

  2. For removing context we can make setContext replace all own properties instead of assigning. This way we can keep API surface smaller.

  3. Speaking about uncovering, consider this:

function* a() {
  yield setContext({foo: 1});
  yield fork(function* b() {
    yield setContext({foo: 2});
    yield fork(function* c() {
      // ..
    });
  });
}

Should c be able to uncover context set in its grandparent a? I think not, because it will break encapsulation.

// TODO: if object returned -> return shallow copy?
// should yield getContext() be available to access context?
// if yes then shallow copying it is a must to avoid unexpected mutations
cb(taskContext[prop])
Copy link
Contributor

Choose a reason for hiding this comment

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

Context property could be anything, not just plain object, so we can't safely copy it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, I think we should throw if property doesn't exist in context to distinguish between the case where context doesn't have a property and the case where context has property with value undefined.

Copy link
Member Author

@Andarist Andarist Jan 1, 2017

Choose a reason for hiding this comment

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

eh, you static typing guys and your habits! 😄

Context property could be anything, not just plain object, so we can't safely copy it.

ofc it would be checked beforehand if it is a plain object to see if we should make a shallow copy

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, I tend to agree with you that throwing is not really necessary here as any relevant checks could be easily performed on the user side.

@Andarist
Copy link
Member Author

Andarist commented Jan 1, 2017

Should generator have access to context properties set by itself? IMO it shouldn't, because you can just use variables for this. So maybe setContext should only have effect on children? Maybe even rename setContext with setChildContext (I don't really like the verbosity though)?

I dont think it will be a problem as this context feature is less convenient to use, so people will always prefer accessing variables than this within task which uses setContext

For removing context we can make setContext replace all own properties instead of assigning. This way we can keep API surface smaller.

I think that could lead to unexpected bugs and also updating the context would be quite unwieldy

Should c be able to uncover context set in its grandparent a? I think not, because it will break encapsulation.

Nah, didnt think about uncovering like this, just 1 step into the upper context like in the example posted here

@aikoven
Copy link
Contributor

aikoven commented Jan 5, 2017

For removing context we can make setContext replace all own properties instead of assigning. This way we can keep API surface smaller.

I think that could lead to unexpected bugs and also updating the context would be quite unwieldy

Another way to avoid unsetContext is to use symbols for context keys on the library user's side.

Context property could be anything, not just plain object, so we can't safely copy it.

ofc it would be checked beforehand if it is a plain object to see if we should make a shallow copy

Is this really a library responsibility? We don't shallow copy selected state and actions returned from take.

@Andarist
Copy link
Member Author

Andarist commented Jan 5, 2017

Context property could be anything, not just plain object, so we can't safely copy it.

ofc it would be checked beforehand if it is a plain object to see if we should make a shallow copy

Is this really a library responsibility? We don't shallow copy selected state and actions returned from take.

Good point, however I feel like context has a higher probability of misusing the feature. With state it is not that much of a problem as there is quite strong mindset that updating the state happens via action + mutating the state breaks connect, so people in general already know that they shouldnt do this.

@aikoven
Copy link
Contributor

aikoven commented Jan 15, 2017

Here's a counterexample:

const obj = {
  foo: null,
  setFoo(foo) {this.foo = foo},
  getFoo() {return this.foo}
};

yield setContext({obj});
// ...
const obj1 = yield getContext('obj');
yield call([obj1, obj1.setFoo], 1);
// ...
const obj2 = yield getContext('obj');
const foo = yield call([obj2, obj2.getFoo]);  // null

Here, the value of obj.foo is lost due to shallow copying. In real world, this could happen when you store observable objects in context and later add callbacks to them.

@Andarist
Copy link
Member Author

Hm, interesting. Its really a scary situation and compelling one at the same time to drop shallow copying. Wondering only which usage would be more often and therefore which approach would give us less footguns in the end.

In theory we could make 2 versions of this effect getContext and getContext.impure

In general im in favor of immutability and this-less programming, aint saying though that everyone should. This feature would be experimental anyway and rather an escape hatch which should noy be used in trivial cases anyway, so... i dont know which is better now as a default

@aikoven
Copy link
Contributor

aikoven commented Jan 15, 2017

In general im in favor of immutability and this-less programming

I absolutely agree with you, but my reasoning is this: for pure things we already have Redux store and actions which we can access in sagas at any level using appropriate effects. We would use Context to access non-pure third-party library objects.

Another use case is to share Channels between sagas, however I guess shallow-copying a Channel would be safe.

subscribe,
dispatch,
getState,
(detached ? null : taskContext), // TODO: topContext should be passed in case of spawn?
Copy link
Member

@yelouafi yelouafi Jan 23, 2017

Choose a reason for hiding this comment

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

why cant we just pass the parent context?

Copy link
Member Author

Choose a reason for hiding this comment

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

reasoning behind it - spawn creates a detached task, which is acting like a top task, so would it be good to propagate taskContext (parent context) to it? behaviour would differ from other propagations (error/cancellation) in terms of detached (spawn) and non-detached tasks (fork)

Copy link
Member

Choose a reason for hiding this comment

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

yeah but you could also view spawn as a simple child task with different behavior regarding cancellation/errors? And also with analogy to lexical scope, it gets its params from its parent task, so could be the same from dynamic scope (context)

Copy link
Member Author

Choose a reason for hiding this comment

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

could be ofc, but what i have proposed felt more natural when i was implementing this, however that was just a question for reviewers (so mostly you :P) left in the source code

also a little bit connected is this - #763 (comment)

in regards of spawn/fork debate, cancel propagation and maybe even need to distinguish rootTask from regular tasks for code-splitting purposes and proper cancelation logic on route changes, would appreciate your input about those matters there greatly!

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree with @yelouafi, if we disable propagating context on spawn, it would make context a much less convenient mechanism for dependency injection.

That said, I think that runSaga should behave just like you said — starting with an empty context by default. Another reason is that otherwise we'd have to somehow link runSaga with sagaMiddleware instance.

}

export function setContext(props) {
// TODO: check if props is object/plain object?
Copy link
Contributor

Choose a reason for hiding this comment

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

Yep, I think we should restrict props to be a plain object.

@@ -91,5 +93,14 @@ export default function sagaMiddlewareFactory(options = {}) {
return task
}

sagaMiddleware.setContext = (props) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the purpose of this API? I can see cases where you need to change the root context as a result of some event, but I think it's more convenient to do this in the root saga.

Copy link
Contributor

Choose a reason for hiding this comment

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

Just found out this was proposed by myself: #713 (comment)

Although I think only one of options.context or sagaMiddleware.setContext() is enough.

Copy link
Member Author

Choose a reason for hiding this comment

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

well, one is needed upon creation and latter might be used in a runtime, could be ofc in response for some action but for more complex contexts it would require action to contain ur dependency which could be not serializable fully, which i dont like - if you for example need to pass some event bus with methods on it

Copy link
Contributor

Choose a reason for hiding this comment

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

Good point. 👍

// TODO: or maybe `setContext` should be available on task and this one should act only as proxy method?
// would that be useful?
// const task = yield takeEvery(...)
// const task.setContext({...}) or maybe even - yield setContext(task, {...})?
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO the context of a task should be a private thing, just like state in React components. The component itself is an owner of its state and it shouldn't be accessible from its parent.

Copy link
Member Author

Choose a reason for hiding this comment

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

actually as React's setState is available on the instance of the component - it could be used outside and therefore its not so private

Copy link
Contributor

Choose a reason for hiding this comment

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

Yep, also I can see some parallel with sagaMiddleware.setContext. Maybe then we just leave task.setContext and remove sagaMiddleware.setContext, as the latter can be done with sagaMiddleware.run(...).setContext?

Copy link
Member Author

Choose a reason for hiding this comment

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

hm... at first I thought that this is a valid point and should be done, but after a while i think in the world of code-splitting its nice to have a possibility to share this top-level middleware context among root sagas, as there could be many of those, and when puting this only on task itself it would require such verbose code in order to share it 'globally':

const context = {...}
sagaMiddleware.run(...).setContext(context)
// ... later
sagaMiddleware.run(...).setContext(context)

Copy link
Contributor

Choose a reason for hiding this comment

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

You're right, I've overlooked the case when sagaMiddleware.run is called multiple times.

Copy link
Member Author

Choose a reason for hiding this comment

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

the only thing im not so happy about is that sagaMiddleware is kinda superior to the runSaga here, as the latter do not have possibility to share such things in an elegant way, but I guess one can always write some wrapper function up to his needs

}

function runSetContextEffect(props, cb) {
Object.assign(taskContext, props)
Copy link
Contributor

Choose a reason for hiding this comment

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

A check for props being a plain object should be added here as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

such checks are usually performed in effect creators, it would only double the same check if we put it here also, as its not possible to reach this - runSetContextEffect in any other way that yield setContext(...)

@@ -153,6 +155,16 @@ export function flush(channel) {
return effect(FLUSH, channel)
}

export function getContext(prop) {
check(prop, is.string, `getContext(prop): argument ${ prop } is not a string`)
return effect(GET_CONTEXT, prop) // TODO: maybe accept functions also?
Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Since we do Object.assign and not deep merge, I'd view context as a flat object. Thus not sure if complex accessors are needed. Maybe I'm missing some use cases, but I'd rather add such features on demand.

@Andarist
Copy link
Member Author

Andarist commented Feb 13, 2017

@aikoven
Last comments made I think (ofc still waiting for your input, if you disagree - or agree :trollface: - somewhere) and I think im gonna update the PR soon so it can get finally merged in

EDIT:// heh, while writing this you have already replied 👍

Copy link
Contributor

@aikoven aikoven left a comment

Choose a reason for hiding this comment

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

I guess we have a consensus finally 😉

@Andarist
Copy link
Member Author

I think the code is final now, so you can have last look on it - whats missing before merging is some tests + docs, ill get them done in this week and merge whole thing to the master.

import proc from './proc'
import { emitter } from './channel'
import { ident } from './utils'

export default function sagaMiddlewareFactory(options = {}) {
export default function sagaMiddlewareFactory({ context = {}, ...options }) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Won't this break if we call factory without arguments?

Copy link
Member Author

Choose a reason for hiding this comment

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

it would, thanks for noticing! gonna fix it with default param in a minute

@AriaMinaei
Copy link

Guys, do you think this branch is going to get merged eventually?

@Andarist
Copy link
Member Author

Andarist commented Mar 8, 2017

@AriaMinaei yes, it sure will! I just need to get some time to merge it and some other branches, so I can release new version, sorry about the delay

@neurosnap
Copy link
Member

Is there anything I can do to help out with this PR?

@Andarist
Copy link
Member Author

@neurosnap, i think its basically done. Will review it again in the weekend and gonna publish

…amic scoping between parent/child tasks (resolves #713)
@Andarist
Copy link
Member Author

Released, not docs yet - but if you know about the feature and want to use it - go ahead. Let me know if everything works as expected.

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.

Connecting to both Redux and non-Redux store
5 participants