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
Immutable API #3548
Immutable API #3548
Conversation
That is amazing. I'll review it very thoroughly on Tuesday utc. Also we need to make sure to backport whatever is released before 3.0 (if we decide to release). Or we can ship this as 3.0-rc1 and keep the two branches for a few months. |
ec9b92b
to
c872241
Compare
Thanks! I'm adding a few comments now about things that I think are worth extra scrutiny, but I'd love to get a comprehensive review. |
src/lib/duration/prototype.js
Outdated
proto.localeData = localeData; | ||
|
||
// Deprecations | ||
import { deprecate } from '../utils/deprecate'; | ||
|
||
proto.toIsoString = deprecate('toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)', toISOString); | ||
proto.lang = lang; | ||
proto.lang = deprecate('duration.lang() is deprecated. Use locale() or localeData() instead.', wrap(Duration, lang)); |
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 lang
method has been deprecated in the docs for several releases now, so I went ahead and added the warning here. But technically we said we'd get rid of all the deprecated stuff in our next major release, so maybe we should just delete old compatibility shims like this...
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.
kill with fire 🔥 🔥 🔥
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 remove everything that has been deprecated besides good old 'moment construction falls back to JS date'. Unfortunately I think we'll break the whole internet if we remove that functionality.
src/lib/locale/constructor.js
Outdated
export function Locale(config) { | ||
if (config != null) { | ||
this.set(config); | ||
set.call(this, config); |
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 got reconfigured because the prototype's copy of set
is wrapped for immutability, which is decidedly unhelpful inside a constructor.
Note that we hadn't explicitly discussed making Locale objects immutable, but it seemed like the obvious thing to do.
@@ -18,16 +18,13 @@ export function locale (key) { | |||
} | |||
} | |||
|
|||
export var lang = deprecate( | |||
'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', |
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 deprecation wrapper got moved to the prototype definition, the same way we handle all the other deprecated APIs.
src/lib/utils/wrap.js
Outdated
@@ -0,0 +1,11 @@ | |||
export default function wrap(Type, fn, dontCloneWithNoArgs) { |
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 curious to know if other folks think this function is sensible. It provides a conveniently concise and reusable API, but I fear the implementation might be a little hard for us humans to parse.
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.
It's clear enough to me. I'm not exactly sure how one would go about doing what you are doing besides this, while still mostly preserving existing code.
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 would put exactly how this works in the contributing file. Also needs to be a standard part of code review to make sure this gets used if it needs to be.
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.
With regard to moment-timezone - it calls update on the moment, and there's no need to be cloning the moment to do that - that's just a perf killer. Can we track updateInProgress in this function and just mutate if that's the case? I think that's fine to do because JavaScript is single threaded (thank god!).
src/lib/moment/get-set.js
Outdated
@@ -43,7 +43,7 @@ export function stringSet (units, value) { | |||
units = normalizeObjectUnits(units); | |||
var prioritized = getPrioritizedUnits(units); | |||
for (var i = 0; i < prioritized.length; i++) { | |||
this[prioritized[i].unit](units[prioritized[i].unit]); | |||
prioritized[i].getSet.call(this, prioritized[i].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.
This change prompted all of the other getter/setter upheaval in this commit. The old line of code didn't work as-is, because the prototype's getter/setter functions will now return new objects instead of mutating the old ones. A simpler change would've been:
var ret = this;
for (var i = 0; i < prioritized.length; i++) {
ret = ret[prioritized[i].unit](units[prioritized[i].unit]);
}
But that would involve generating a bunch of unnecessary moments. At the time I wrote this commit, I was worried about the resulting performance impact. I became somewhat less concerned about keeping copies to a minimum in later commits, after I discovered that the updateOffset
API requires some "extra" copying no matter what.
Let me know if you'd like to see any changes in the way I've balanced performance vs readability throughout this PR.
value: unitsObj[u], | ||
priority: priorities[u].priority, | ||
getSet: priorities[u].getSet | ||
}); |
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.
All the getter/setter functions get registered here, in addition to registering them directly on the Moment prototype as before, so that we can access their unwrapped implementations in the generic .set()
API (aka "stringSet", where I left the other comment above).
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, this code was a bit too "advanced" :)
|
||
if (keepLocalTime) { | ||
this.subtract(getDateOffset(this), 'm'); | ||
ret = ret.subtract(getDateOffset(ret), 'm'); |
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.
On the other hand, I obviously wasn't quite as worried about creating intermediate copies when I updated this function...
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 don't see an issue about intermediate copies. In general, with immutable API, how do you avoid them?
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.
If we'd done our original plan of wrapping mutable internals, then we wouldn't have intermediate copies within the library implementation. But this comment is outdated -- we decided to work toward immutable internals instead, to make future development more understandable.
updateInProgress = true; | ||
hooks.updateOffset(this); | ||
updateInProgress = false; | ||
} |
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 moved this code to createFromConfig
, which seems to cover all cases of creating Moments from non-Moment input. In doing so, I'm assuming that it's safe to clone any Moment without running updateOffset
. That seems like a reasonably safe assumption, but I'd like to highlight it in case there's anything I'm not aware of.
When I kept the updateOffset call here, I had a subtle bug with the way moments got cloned when the parseZone
algorithm calls the wrapped copy of utcOffset from our prototype. It occurs to me now that I probably could've fixed that by (1) re-ordering operations within parseZone
, and/or (2) using the unwrapped implementation behind utcOffset
instead of simply calling the prototype's copy. I'd be happy to try replacing this with one or both of those solutions if you're concerned about this particular edit -- but if this edit seems safe, I think it's probably faster and easier to reason about.
(Note that this edit avoids the messiness of trying to make an immutable updateOffset work inside the constructor:
updateInProgress = true;
var updated = hooks.updateOffset(this);
copyConfig(this, updated);
this._d = updated._d;
updateInProgress = false;
Which is ugly and annoying.)
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 not sure I follow you here (maybe this comment is outdated). I'm left with the impression that there is no "wrap" any more.
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, most of this comment is outdated. The first paragraph is still true -- I've still got this call to updateOffset
in create/from-anything.js
-- but not for any particularly great reason anymore, so I should probably just move it back to the constructor.
src/lib/duration/constructor.js
Outdated
// _bubble returns a new Duration because immutability, | ||
// but here in the constructor we need to update `this` with the new values | ||
var bubbled = this._bubble(); | ||
copyDuration(this, bubbled); |
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.
Yuck. I don't see a way around this, though, unless we un-publish the _bubble
method so that it can remain an unwrapped mutator.
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.
Aaand I got my commits out of order while rebasing things. The implementation of copyDuration
appears in the next commit. (In retrospect, I should've just squashed these two commits into one.)
Not sure how everyone else feels, but my inclination with this PR is to divide up the tests between me, @icambron , @mj1856, and @ichernev. Each of us can take a set to review. You're then responsible for reviewing the entire code path down from the test. This will cause the most commonly used code paths to be reviewed several times, and the less commonly used to be reviewed once. |
Also, we're going to probably have a few merge conflicts with existing PRs. Not sure on the strategy for resolving that, but to me it makes sense to start merging them to develop, even if we aren't planning on a release. I think poor Lucas is going to be stuck with resolving for a large part, though we all can. |
So long as we don't do another ES6 rewrite in the meantime, I think merging changes from develop between now and 3.0 should be a doable (albeit large) task. After all, I've done it before. |
OK, here are my notes:
I'm a bit lost on something -- how come the plugin did work while not changing any code inside moment. Because it had both frozen and mutating API, and the inners where always using the mutating api, the frozen stuff was only for the user, using the mutating api + clone. Couldn't we just hide the mutating API from the user and only expose the frozen, which will be autogen with the plugin code? Just overall -- I was expecting a change that was basically the plugin code shipped with the core code. If we spend time to make it properly immutable we might as well do that and remove wrap from the remaining methods. The core mutating methods are not that much, most rely on other mutating methods. |
Re @ichernev:
6-11. Ooh, good point, I should use #3134 as a starting point. Without updateOffset in the mix, I should be able to keep everything internally mutable, more like the plugin. (During this work, I realized that my plugin presumably never worked properly with updateOffset / Moment Timezone.) -12. I did all of these tests by hand, and I don't have a better system in place for porting locales and their tests. I'm tempted to try writing a script to do simple code rewriting (assigning the result of statements that have mutation methods back to the original variable name) and see how far that gets us, but I haven't done that sort of coding in JS before. Suggestions welcome here. Re: "I'm a bit lost" -- because |
One concern about moving this change on top of 3134 - code churn. It feels like that introduces a large number of changes at once. 3134 is a big deal. If we go down that road, I would get 3134 shipped in moment 3.0, and follow about two or three months later with moment 4.0 being immutable. I'm okay with that if it's the right decision for the code, but I would not send those two things together. |
@butterflyhug just verifying -- the whole reason for the internal mutability is updateOffset? Now that I think about it -- its hard now, because update offset is called on every mutator and might mutate again. But I don't buy the performance reason. Right now we clone the whole moment object on every mutation just because we might mutate it with updateOffset. This should be much more inefficient. If updateOffset was made to return a new moment it will only happen very rarely, and all mutators can just create one copy, correct from the start. I'd ship the two changes together. I disagree with @maggiepint because, the updateOffset needs to match the immutability in 3.0. So we can't just ship one without the other. We could ship only new TZ interface, but I do not consider it a big change. Because it is kind of internal API to moment-timezone. If somebody was using it -- he can't in an immutable version, so porting your code to immutability should include that small nuance (updateOffset). |
@ichernev Yes, I think we're basically saying the same thing in slightly different words. With
Either way, updateOffset implementations won't be able to rely on adding new properties to the moment being updated anymore (e.g. these tests) -- but that's true for any of the immutable APIs so it's not really a surprise. The current code in this PR is a middle ground between these two end states, because I was indecisive. You're right to flag the inconsistency as a major issue, so I should ideally bring the code into alignment with whichever path we'd prefer to maintain over the long term. |
And if we're willing to remove |
@butterflyhug why is the new api better for mut internals and immut api? I think I like the first option more. The problem is some methods are slower but that's what people want. The problem is, having the mutable methods around is very flexible, allowing us to do a lot of questionable magic, like auto-batch mutations (record mutations and batch exec them on read) or provide a mutable interface directly to speed things up. Ideally we'd do one or both (your suggestions) and benchmark with existing code. |
@butterflyhug I'm pretty sure if we introduce the new TZ api we need to get rid of If we're to have half our methods mutable internally, half immutable then we need veery good guidelines on what and where needs to be mutable and try to decrease that to a minimum. And possibly a package (directory) inside |
@ichernev Right, okay. That's what I initially thought. The Timezone interface would be better for mutable internals and immutable API. Moment-internal code would ask the Timezone for the correct offset and update the Moment instance when appropriate. The problem with the updateOffset API is that we have external code (from Moment Timezone) that needs to edit a moment in the middle of the internal mutable code path. That's fundamentally at odds with building a clean internal=mutable / external=immutable boundary. Of course we can hack around the issue, but it's cleaner if we don't need to. |
Friendly reminder: the moment-timezone code to make the TZ interface work has not yet been written :-) |
@maggiepint it hasn't. But I fail to find a better interface for updating the offset. So more or less it will be what the rfc says. If you have another idea that will magically solve the issue at hand I'm all ears. |
c872241
to
a1dea22
Compare
f6104a5
to
e65ca1d
Compare
moment#clone() is now a deprecated no-op, because immutability.
The bubble function now returns a new data object instead of mutating the duration's existing data (which had always been empty when we called _bubble anyway).
e8fedc0
to
ab03657
Compare
Also make the Duration#add and Duration#subtract implementations internally immutable (so we don't need to wrap them for the prototype).
Also ensure that we always test both the 11:00 hour and the non-11:00 formatting variations, instead of simply testing whichever hour it happens to be when we run the test.
ab03657
to
7c0d219
Compare
Okay, I've updated this code and it should read a bit better now. For internal consistency, all prototype methods now have immutable implementations instead of using a Frozen Moment-style I did not remove old deprecated functions here; that will be a quick and easy change for 3.0, but I think it belongs in a separate PR. This is also still using I haven't written any new benchmarks to test things thoroughly, but based on existing resources, the performance impact seems to be relatively small:
|
Oh, and this is rebased on top of last night's v2.18.1 release. |
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.
When I was reviewing two things popped to my mind:
- we need to provide an interface for users to check if the moment is mutable or not (because with 3.x and 2.x out in the wild it will be hard to tell)
- we need to do somehow test that moment is actually immutable (that is -- all functions don't change anything). Even if it is now, we'll have trouble keeping it this way just by "looking at the code".
The one thing that still bothers me with the code itself is the way we make changes to internals, just after clone in a bunch of methods (places where we call new Moment(...)). In the places where we modify _d, couldn't we just take _d, clone it, modify it and then create a moment with it -- so modify before clone, not after. Now this is dangerous because could mess something up, when its spread to many places (12 right now). On the other hand we can ship this way and work our way toward all-mutability-in-constructor in future (not necessarily major) versions.
if (!keepLocalTime || mom._changeInProgress) { | ||
mom = addSubtract(mom, createDuration(input - offset, 'm'), 1, false); | ||
} else if (!mom._changeInProgress) { | ||
mom._changeInProgress = true; |
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 _changeInProgress was there because of mutability. Now I don't think its necessary. Or maybe it is necessary, but we should put it on the new moment, so it want fall into a cycle. Maybe that is related to the other piece of cycle-break-check in the constructor.
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.
Oh, as written this is a bug! I agree that we probably don't need it anymore; I'll try removing it. Good catch!
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 actually need to implement the the new moment-timezone that handles both 2.x an 3.x moments.
|
||
if (keepLocalTime) { | ||
this.subtract(getDateOffset(this), 'm'); | ||
ret = ret.subtract(getDateOffset(ret), 'm'); |
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 don't see an issue about intermediate copies. In general, with immutable API, how do you avoid them?
updateInProgress = true; | ||
hooks.updateOffset(this); | ||
updateInProgress = false; | ||
} |
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 not sure I follow you here (maybe this comment is outdated). I'm left with the impression that there is no "wrap" any more.
newLocaleData = getLocale(key); | ||
if (newLocaleData != null) { | ||
this._locale = newLocaleData; | ||
clone._locale = newLocaleData; |
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 get a bit uneasy when reading this. Sure, its a new clone, but I can see a bug slipping through if one is not very careful. Would it be possible to cram all the "mutability" inside the constructor, so it gets all arguments and produces a frozen (maybe not literally) moment.
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.
Sure, I'll take a stab at that.
// the following switch intentionally omits break keywords | ||
// to utilize falling through the cases. | ||
switch (units) { | ||
case 'year': | ||
this.month(0); | ||
m = m.month(0); |
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 say this particular function could use a lower level API to avoid unnecessary cloning. Because resetting all units lower than a given unit is the most expected operation dealing with multiple units and it would be nice if it was fast. Of course we can ship this way, and fix (improve) later.
value: unitsObj[u], | ||
priority: priorities[u].priority, | ||
getSet: priorities[u].getSet | ||
}); |
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, this code was a bit too "advanced" :)
src/lib/units/offset.js
Outdated
@@ -196,8 +203,8 @@ export function hasAlignedHourOffset (input) { | |||
|
|||
export function isDaylightSavingTime () { | |||
return ( | |||
this.utcOffset() > this.clone().month(0).utcOffset() || | |||
this.utcOffset() > this.clone().month(5).utcOffset() | |||
this.utcOffset() > new Moment(this).month(0).utcOffset() || |
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 is this needed? Can't we just .month(X).utfOffset()
?
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.
Hmm, yes, I think this is a stale change. I'll simplify this as you suggest.
One more thing before I forget -- we might need allow some APIs for plugins to hook into the constructor, because if they need to keep a piece of data with moment, now that its immutable it needs to be in the constructor, but we don't have any hooks for that. Or maybe plugins don't need to keep data in moment, or maybe the data should be only lazily computed (and keep moment at least observably immutable). |
I think it's probably best for plugins to lazily compute values. It's possible for someone to create a moment instance before loading all their plugins, so ideally plugins will need to handle the case where they didn't exist when the constructor ran anyway. I suspect they're more likely to do it correctly if we make that the normal case. 😉 Also, I'm sure we'll still be lazily computing a few things in the core library unless we do a rigorous code audit specifically aimed at moving all lazy computations into the constructor. As long as nobody ever updates existing values (and all new values are derived solely from the moment's existing data), I think it'll be fine. That said: we/I should probably write a guide to help plugin authors to update their code, and this would be a good issue to highlight in that doc. |
Also: the proposal for distinguishing immutable from mutable moments was to add a Thanks for the thorough review, @ichernev! |
I'd rather add moment.isImmutable property. With version you have to split and compare and so on. I didn't think too much but we should be able to hack some code that verifies all moment properties did not change after each function (for tests only). And we need to consider lazily computed props and not fail if those change. |
I added moment.isImmutable and fixed unnecessary cloning.
Yeah, I think we can safely close this. |
Maybe we add a note to the moment readme to point to LUXON? The not-immutable was the first pain point with the momentjs for me so it would be nice to know that exist a new improved tool for it |
This is an implementation of moment/moment-rfcs#2, which was written to address #1754. This is obviously a breaking API change that would require a new major version number (e.g. "3.0").
The only major change from the RFC draft's discussion is that
moment.updateOffset()
andmoment.duration()._bubble()
will now return moment and duration objects, respectively. Otherwise it's pretty much impossible for external code to customize the behavior of these hooks.Feedback welcome! 😃