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

Make got.mergeOptions() behavior more obvious and document its behavior #538

Merged
merged 7 commits into from
Jul 31, 2018

Conversation

jstewmon
Copy link
Contributor

Option merging is now consistent, so null header removal is done during
options normalization. null could not be used to indicate property
removal because null is a significant value for some settings.

}
function customizer(objValue, srcValue) {
if (is.array(srcValue) || is.array(objValue)) {
return cloneDeep(srcValue);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This might not be necessary. Maybe we can just pick the right target based on whether is.array(objValue). I was running short on time, so I did this to get it working.

I think we could ditch cloneDeep as a dep if that works.

@jstewmon jstewmon changed the title use lodash to merge options and document assignOptions behaviors make assignOptions behavior more obvious and document its behavior Jul 26, 2018
for (const [key, value] of Object.entries(options.headers)) {
if (is.nullOrUndefined(value)) {
delete returnOptions.headers[key];
function merge(target = {}, ...sources) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

target = {} is useless here

const a = got.extend({baseUrl: new URL('https://example.com')});
const b = a.extend({baseUrl: '/foo'});
t.is(b.defaults.options.baseUrl.toString(), 'https://example.com/foo');
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

@@ -1,27 +1,30 @@
const extend = require('extend');
const url = require('url');
Copy link
Collaborator

Choose a reason for hiding this comment

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

use const {URL} = require('url');

test/create.js Outdated
const a = got.extend({retry: {statusCodes}});
t.deepEqual(a.defaults.options.retry.statusCodes, statusCodes);
t.is(a.defaults.options.retry.statusCodes, statusCodes);
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider this example:

test('extend overwrites arrays', t => {
	let statusCodes = [408];
	const a = got.extend({retry: {statusCodes}});
	statusCodes[0] = 500;
	t.deepEqual(a.defaults.options.retry.statusCodes, [408]);
	t.not(a.defaults.options.retry.statusCodes, statusCodes);
});

It shouldn't fail. It should deep copy arrays IMO.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh. Defaults are deep frozen here:

value: deepFreeze(defaults),

and ain't cloned before.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I made it only deep copy plain object, since all other types are merged by replacement.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, I made it only deep copy plain object, since all other types are merged by replacement.

I don't think obvious things need an explanation, do they? You've just repeated yourself: that's what your code does, doesn't it? Please come up with a real argument. I'll illustrate if you have problems with understanding mine:

  1. Someone creates an array.
  2. The array is passed to assignOptions through got.extend().
  3. It is passed by reference (please keep that word in mind my friend).
  4. It's deep frozen (got.extend() -> got.create() -> deepFreeze())
  5. Someone wants to reuse the array.
  6. 💥

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I understand that concern, but I intentionally kept the implementation simple, so that the results are predictable.

I was also concerned with performance, since this method is called not only by got.extend and got.create, but also by every call to got().

There are an infinite number of possible reference types that inherit from object, which may also have unexpected behavior when cloned, especially when only an object's own properties are cloned.

I reluctantly chose to recursively merge source values that are plain objects when the target value is not a plain object for this reason because normalize-arguments currently requires that the options it receives are mutable. I would rather have refactored normalize-arguments, so that it selectively builds a new object from the input, but I thought that was too large a change to introduce here.

Do you think it would be helpful to explicitly mention in the got.extend and got.create that the resulting object is frozen, so that any reference types will be frozen? We could also mention that a workaround is to use something like _.deepClone if users need to preserve a mutable instance of a config setting.

We could also reconsider whether freezing the options tips the balance of predicable behavior nearer or farther from the user's expectations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just clarify my position - I'm not strictly inflexible on the implementation, but I want to make sure my rationale is clearly expressed before we decide to change it.

@sindresorhus do you have an opinion on this topic?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I was also concerned with performance, since this method is called not only by got.extend and got.create, but also by every call to got().

Then why? There are many nitpicks that could improve performance a bit (it's unnoticeable) like this one and you're nothing about that. Please, be serious.

Do you think it would be helpful to explicitly mention in the got.extend and got.create that the resulting object is frozen, so that any reference types will be frozen?

No. That's dumb. All plain objects are frozen and other objects like arrays are not? That doesn't have any sense. BTW, It's OK to pass references to other types than objects and arrays since got doesn't use them.

Copy link
Owner

Choose a reason for hiding this comment

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

Do you think it would be helpful to explicitly mention in the got.extend and got.create that the resulting object is frozen, so that any reference types will be frozen? We could also mention that a workaround is to use something like _.deepClone if users need to preserve a mutable instance of a config setting.

Yes, that would be useful.

test/create.js Outdated
t.true('headers' in got.defaults.options);
const a = got.extend({headers: undefined});
t.false('headers' in a.defaults.options);
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

👎 It should keep the old value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm fine either way on this one.

I interpreted the previous behavior as undefined being a sentinel value that can be used to remove a setting, i.e. a discrete feature of got's extension behavior.

There is a test that verifies that null headers are omitted from the normalized arguments, but I've addressed that case in normalize-arguments.

@sindresorhus do you have a preference?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Look at the other merge / extend libs :) They omit undefined values. I mean users would think that if you leave another property undefined it will remain unchanged. It's header-specific.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to clarify, based on your complete suggestion below, you think undefined values in the source should be noop and the target value should be preserved, right?

That is what lodash.merge does, but I don't understand the rationale in that approach. If the key exists in the source, I think it expresses some intent of the caller vs the value being undefined b/c the key does not exist.

I would suggest that when the key exists in the source and the value is undefined, the result should either be:

  • omit the key from the result
  • set the key to undefined in the result

I could be persuaded that the source value should be ignored, but I the rationale isn't obvious to me.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Just to clarify, based on your complete suggestion below, you think undefined values in the source should be noop and the target value should be preserved, right?

yes.

Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the rationale for, or problem solved, by deep merging arrays?

You don't even simply clone them, so let's start with that. With arrays there should be no exception. I'm not gonna explain simple things.

I really appreciate you want to do it on your own :)

Copy link
Owner

Choose a reason for hiding this comment

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

I'm not gonna explain simple things.

Arguments are worthless if the other part doesn't understand you. Not everyone is in your exact mindset. We all need to be on the same page. I was also confused by your comment.

Copy link
Owner

@sindresorhus sindresorhus Jul 27, 2018

Choose a reason for hiding this comment

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

I just realized that my proposal is how lodash.merge works.

But, I also can't think of a reason why an object would contain a key with the value set to undefined in this context b/c the key could simply be omitted to get the same behavior.

It allows you to do inline conditionals:

foo({
	unicorn: checkForUnicorns() && '🦄'
});

Without, I would have to do:

const options = {};

if (shouldUseUnicorns()) {
	options.unicorn = '🦄';
}

foo(options);

I'm not yet sure it's worth the divergence from Object.assign/object-spread. There's value having the same semantics as the native methods. Especially when our method is using the word assign.

I backed off your change b/c it overreached what we had discussed. I don't mind maintainer commits for nits or agreed upon changes, but please don't make behavioral changes when consensus has not been reached.

@szmarczak Don't modify other people's PRs before we have agreed on a solution.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@sindresorhus thanks for providing that example. I can appreciate the convenience that provides.

It occurred to me that, in the future, we could expose a symbol support deleting a key if the need for it arises. Similarly, we could expose a symbol to indicate that the result should be undefined if there's a need to explicitly set a key to undefined.

Using symbols for those cases would have the disadvantage of being a unique approach, but it would have the advantage of our interface being optimized for the most common scenarios.

If we're settled on ignoring undefined, we could rename assignOptions to mergeOptions to help clarify that its behavior is not congruent with Object.assign.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Arguments are worthless if the other part doesn't understand you. Not everyone is in your exact mindset. We all need to be on the same page. I was also confused by your comment.

I'll sum up: going through shortcuts won't end well, just a hint. Another hint: references, arrays, deep freeze. This says a lot.

Don't modify other people's PRs before we have agreed on a solution.

I've just proposed a solution based on yours. Do what you want, I'm off. I'll be happy with any (current one is cool too).

test/create.js Outdated
b.defaults.options.headers,
{...got.defaults.options.headers, ...{baz: 'baz', bar: null}}
);
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

👎 The feature is meant only for headers. Otherwise, if the new value is undefined, it shouldn't be replaced.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ack. This is the same behavior as we're discussing above, so we'll resolve this when we resolve that.

Copy link
Collaborator

@szmarczak szmarczak left a comment

Choose a reason for hiding this comment

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

I suggest this:

const merge = (target, ...sources) => {
	for (const source of sources) {
		for (const [key, sourceValue] of Object.entries(source)) {
			const targetValue = target[key];
			if (!is.undefined(sourceValue)) {
				if (is.urlInstance(targetValue) && (is.urlInstance(sourceValue) || is.string(sourceValue))) {
					target[key] = new URL(sourceValue, targetValue);
				} else if (is.plainObject(sourceValue)) {
					if (is.plainObject(targetValue)) {
						target[key] = merge({}, targetValue, sourceValue);
					} else {
						target[key] = merge({}, sourceValue);
					}
				} else if (is.array(sourceValue)) {
					target[key] = merge([], sourceValue);
				} else {
					target[key] = sourceValue;
				}
			}
		}
	}

	return target;
};

readme.md Outdated
@@ -408,14 +406,23 @@ client.get('/demo');

#### got.assignOptions(parentOptions, newOptions)

Extends parent options. Avoid using [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#Spread_in_object_literals) as it doesn't work recursively:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd leave this note about avoiding object spread because mostly that's not what users want.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I pruned it because I didn't think it was necessary for us to document the language feature, and the reader's attention to detail is inversely related to the length of the docs.

I can put it back if you feel strongly that it will meaningfully assist users in making the right choice.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@sindresorhus What are your thoughts on that?

Copy link
Owner

@sindresorhus sindresorhus Jul 27, 2018

Choose a reason for hiding this comment

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

I think it's worth keeping. I see the mistake of using Object.assign/object-spread for deep objects all the time. This also gives context to why we have this custom method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

On closer inspection, I didn't actually remove this - I just inserted the function description before the warning. :-)

Jonathan Stewmon and others added 3 commits July 26, 2018 11:06
Option merging is now consistent, so null header removal is done during
options normalization. null could not be used to indicate property
removal because null is a significant value for some settings.
We have a narrow set of rules for merging objects and lodash's more
aggressive merge may have unintended consequences when the values
being merged are not plain objects.
@sindresorhus sindresorhus changed the title make assignOptions behavior more obvious and document its behavior Make got.assignOptions() behavior more obvious and document its behavior Jul 27, 2018
test/headers.js Outdated
});
const headers = JSON.parse(body);
t.false(Reflect.has(headers, 'user-agent'));
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wouldn't support this, as it might be confusing with got.extend({ ... }), where setting to undefined keeps the old value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test had to be rewritten to use a header key that doesn't have a default value. The behavior is consistent as you wished. :-)

@@ -145,9 +145,9 @@ test('remove null value headers', async t => {
test('remove undefined value headers', async t => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Wait. What's with user-agent? You need to change the test name if you change the behaviour too.

test/create.js Outdated
});

test('extend ignores object values set to undefined (root keys)', t => {
t.true('headers' in got.defaults.options);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use Reflect.has() for consistency.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also I suggest figuring out a better test name.

test/create.js Outdated
t.is(a.defaults.options.retry.statusCodes, statusCodes);
});

test('extend overwrites null', t => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

After I read the test name I still don't know what's this about.

readme.md Outdated
@@ -358,10 +358,8 @@ Sets `options.method` to the method name and makes a request.

#### got.extend([options])

Configure a new `got` instance with default `options` and (optionally) a custom `baseUrl`:
Configure a new `got` instance with default `options`. `options` are merged with the extended instance's `defaults.options` as described in [`got.assignOptions`](#got.assignoptionsparentoptions,-newoptions).
Copy link
Collaborator

Choose a reason for hiding this comment

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

Stop guessing. You need to change the URL to #gotassignoptionsparentoptions-newoptions.

@sindresorhus
Copy link
Owner

I've decided. Let's go with ignoring undefined and rename the method to mergeOptions.

I think we should also deep clone the array, not because it solves all the problems, but it makes it less likely that the user will encounter a problem with references, and I think that is worth the neglible performance we save by not doing it. This is also how lodash.merge works and what users would expect:

Array and plain object properties are merged recursively. Other objects and value types are overridden by assignment. Source objects are applied from left to right. - lodahs.merge docs

We can pretty much reuse the above description for our mergeOptions docs.

@jstewmon
Copy link
Contributor Author

@sindresorhus I wasn't sure whether you meant to just make a copy of the source array or merge the array into the target's property value (like lodash). I assumed (and implemented) the former, since that's closest to what we've been discussing, but your comment about lodash gave me some doubt.

@szmarczak
Copy link
Collaborator

szmarczak commented Jul 29, 2018

@jstewmon You forgot git add ;) There's no merge-options file.

- rename assignOptions to mergeOptions
- update docs accordingly
@szmarczak
Copy link
Collaborator

szmarczak commented Jul 30, 2018

@jstewmon I would send a review, but you've blocked me. 🎉 Congratz.

  1. Avoid new Array. See this for more explanation.

  2. What does extend overwrites null mean?

Also please test markdown links because the ones you provided don't work.

@szmarczak szmarczak dismissed their stale review July 30, 2018 08:45

It's no longer applicable.

@sindresorhus
Copy link
Owner

sindresorhus commented Jul 31, 2018

I wasn't sure whether you meant to just make a copy of the source array or merge the array into the target's property value

I meant, make a copy, yes.

@sindresorhus sindresorhus changed the title Make got.assignOptions() behavior more obvious and document its behavior Make got.mergeOptions() behavior more obvious and document its behavior Jul 31, 2018
@sindresorhus sindresorhus merged commit 5562be2 into sindresorhus:master Jul 31, 2018
@sindresorhus
Copy link
Owner

Thanks for the PR, @jstewmon :)

Repository owner locked as too heated and limited conversation to collaborators Jul 31, 2018
@jstewmon jstewmon deleted the assignOptions-mergeWith branch August 24, 2018 17:11
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants