Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

T/897: Make Position/Range immutable. #1175

Merged
merged 17 commits into from
Nov 24, 2017
Merged

T/897: Make Position/Range immutable. #1175

merged 17 commits into from
Nov 24, 2017

Conversation

jodator
Copy link
Contributor

@jodator jodator commented Oct 25, 2017

Suggested merge commit message (convention)

Other: Make Position and Range immutable in model and view. Closes ckeditor/ckeditor5#4032.

BREAKING CHANGE: model.Position#offset and view.Position#offset now have only a getter. To modify offset use Postion#getShiftedBy() or Position#getShiftedTo().

BREAKING CHANGE: model.Position#path and view.Position#path now have only a getter and are immutable.

BREAKING CHANGE: model.Range#start and model.Range#end properties now have only a getter.

BREAKING CHANGE: view.Range#start and view.Range#end properties now have only a getter.


Additional information

This PR required minor changes in other repos (centralized in: https://github.com/ckeditor/ckeditor5/tree/t/ckeditor5-engine/897)

Merge commit message for related branches:

Internal: Aligned Positions and Ranges usage to the latest API of the framework (see ckeditor/ckeditor5#4032).

Other:

  • Main idea behid this PR is to reduce the need for creating new Position/Range objects when using it in other methods. Now those object can be safely passed around without side effects.
  • The remove() method in view/writer still does something with passed

@scofalik scofalik self-requested a review October 25, 2017 12:23
@scofalik
Copy link
Contributor

scofalik commented Nov 9, 2017

I just started to review this, but I wonder if it will be a positive change at all. AFAICS now positions will be created in some scenarios when they weren't, pretty much a lot in OT.

Edit: Maybe when we will refactor OT we will be able to introduce methods that work on paths directly instead of positions and ranges, to reduce the number of created Position and Range instances.

@@ -179,25 +179,29 @@ const ot = {
// Take the start and the end of the range and transform them by deletion of moved nodes.
// Note that if rangeB was inside AttributeOperation range, only difference.end will be transformed.
// This nicely covers the joining simplification we did in the previous step.
difference.start = difference.start._getTransformedByDeletion( b.sourcePosition, b.howMany );
difference.end = difference.end._getTransformedByDeletion( b.sourcePosition, b.howMany );
const range = new Range(
Copy link
Contributor

Choose a reason for hiding this comment

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

Please change this const name to differenceTransformed.

}

if ( common !== null ) {
// Here we do not need to worry that newTargetPosition is inside moved range, because that
// would mean that the MoveOperation targets into itself, and that is incorrect operation.
// Instead, we calculate the new position of that part of original range.
common.start = common.start._getCombined( b.sourcePosition, b.getMovedRangeStart() );
common.end = common.end._getCombined( b.sourcePosition, b.getMovedRangeStart() );
const range = new Range(
Copy link
Contributor

Choose a reason for hiding this comment

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

commonTransformed.

// This position can't be affected if deletion was in a different root.
if ( this.root != deletePosition.root ) {
return transformed;
return this;
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we sure this is not harmful in any way? I know that positions are now immutable but this method, by name, suggests that a new instance is returned. I think we don't have (and maybe won't have) such case in code, but one thing that comes to my mind is storing values in Map with Positions as keys.

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.. maybe I've gone too deep into removing new instances. Here might be better to always create a copy.

// This position can't be affected if insertion was in a different root.
if ( this.root != insertPosition.root ) {
return transformed;
return this;
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as above.

};
}

/**
* Returns a copy of this position that is updated by removing `howMany` nodes starting from `deletePosition`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Docs might need an update if we won't be returning a copy in every scenario (see comment in code below).

// @private
// @param {Array.<Number>} path Position path. See {@link module:engine/model/position~Position#path}.
function getOffset( path ) {
return last( path ) || 0;
Copy link
Contributor

@scofalik scofalik Nov 9, 2017

Choose a reason for hiding this comment

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

Why || 0? Wouldn't undefined be better?

Anyway, it is an incorrect situation if path has no items. So maybe just return last( path );. I am not even sure we need this helper if it is that easy.

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 didn't want to change logic behind this AFAIR. It was:

get offset() {
    return last( path ) || 0;
}

I don't know if it would have any side effects or not.

Copy link
Contributor

Choose a reason for hiding this comment

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

Weird. Position always has to have an offset :S. Try if anything breaks without it ;).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@scofalik: I was able to fail a test after removing || 0:

expect( new Position( root, [ false ] ) ).to.have.property( 'offset' ).that.equals( 0 );
expect( new Position( root, [ undefined ] ) ).to.have.property( 'offset' ).that.equals( 0 );

But this looks like a bad API use - so we might as well drop || 0 check as position accepts only non-empty array as path.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I'd remove || 0 and the test.

@@ -804,13 +829,13 @@ export default class Range {
// 4. At this moment we don't need the original range.
// We are going to modify the result and we need to return a new instance of Range.
// We have to create a copy of the reference range.
const result = new this( ref.start, ref.end );
let result = new this( ref.start, ref.end );
Copy link
Contributor

Choose a reason for hiding this comment

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

AFAICS, range result is not used directly, just its start and end properties. Maybe we could just go with storing Positions and it would be better?

@@ -204,33 +204,37 @@ export default class TreeWalker {
*/
_next() {
const previousPosition = this.position;
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we can drop position variable in this function? It is confusing and I am not really sure it is needed.

@@ -278,27 +285,31 @@ export default class TreeWalker {
*/
_previous() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same with position here.

@@ -187,7 +187,7 @@ export default class TreeWalker {
* @returns {module:engine/view/treewalker~TreeWalkerValue} return.value Information about taken step.
*/
_next() {
let position = Position.createFromPosition( this.position );
let position = this.position;
Copy link
Contributor

Choose a reason for hiding this comment

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

Same with position.

@@ -292,7 +293,7 @@ export default class TreeWalker {
* @returns {module:engine/view/treewalker~TreeWalkerValue} return.value Information about taken step.
*/
_previous() {
let position = Position.createFromPosition( this.position );
let position = this.position;
Copy link
Contributor

Choose a reason for hiding this comment

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

Same with position.

@scofalik
Copy link
Contributor

scofalik commented Nov 9, 2017

All in all, good job! @Reinmar can you check what impact does it have on the original issue?

@scofalik
Copy link
Contributor

scofalik commented Nov 9, 2017

Could you make PRs out of commits in other repos?

@scofalik
Copy link
Contributor

scofalik commented Nov 9, 2017

@Reinmar @jodator Original issue described problem with copying selection. Unfortunately, this PR only partially fixes it. Selection#_pushRange is still the same. One of the reasons is that we don't want people to push LiveRanges to Selection. So there is still Range#createFromRange() there. What do we do with it?

Edit: sorry, I got confused -- above concerns model.Selection, the issue was about view.Selection (I think so, as there is no getTrimmed() method in model.Range).

Edit2: So I guess, that this should change too:

* getRanges() {
	for ( const range of this._ranges ) {
		yield Range.createFromRange( range );
	}
}

Both in model.Selection and view.Selection?

Same with getFirstRange and getLastRange.

* @param {module:engine/model/position~Position} position Position of which path should be modified.
* @param {Array.<Number>} path Position path. See {@link module:engine/model/position~Position#path}.
*/
export function setPath( position, path ) {
Copy link

Choose a reason for hiding this comment

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

I would make this a protected class method.

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 mixed feelings about this.

From one point we don't want to mess around with position path. On the other hand, though, we need this or LivePosition won't work (there is an exactly same problem with LiveRange#start and LiveRange#end).

If these are protected methods then we don't have to really refactor some parts of the code. Just use the new protected methods! But is this really the way to go?

So IDK, I am torn. I liked this as a utility function because it is more discouraging to use it. But IDK. Maybe LiveRange should be in the same file as Range. Then we would just use the symbol defined in range.js.

Copy link

Choose a reason for hiding this comment

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

For me, it the matter of convention. We have classes with protected methods exposed for other related classes in modules. For instance:

We already had a talk about exposing protected methods for child classes at some point of designing editor class. We never did it as a separate export.

Copy link
Contributor

Choose a reason for hiding this comment

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

I know what is the purpose of @protected tag and how we use it. The question is whether we want to have such @protected method at all. Because if we do, we can revert (or rather re-do) many of the changes proposed in this PR (at least in OT and tree walkers as these are used multiple time consecutively).

Copy link

Choose a reason for hiding this comment

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

Now I see that also there is a symbol used for the property. I would go with the @protected property (without a symbol) and public getters. We have such pair already in some places in the code. We can even write that the property is exposed only for LiveRange and should not be used anywhere.

@@ -13,6 +13,9 @@ import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
import Text from './text';

const _path = Symbol( 'path' );
const _root = Symbol( 'root' );
Copy link

Choose a reason for hiding this comment

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

We don't use symbols for private properties. @private is enough.

Copy link
Contributor

Choose a reason for hiding this comment

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

It is not really @private, it is read-only. Also, we do use Symbols in a couple of places. I think these are fine here.

Copy link
Contributor

Choose a reason for hiding this comment

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

But symbols make them private too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But symbols make them private too.

A bit better hidden but also accessible (more work required though...) also not enumerable.

But I get that in CKEditor 5 there is already established convention and using straightforward this._path in child classes would be simpler.

I only don like "duplication" on object as the getter this.path would just return this._path.

Copy link

Choose a reason for hiding this comment

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

Feel free to create a separate ticket to change it, but since we already do it this way, we should follow the convention:

get document() {
return this._doc;
}

get isFake() {
return this._isFake;
}
/**
* Returns fake selection label.
*
* @see #setFake
* @returns {String}
*/
get fakeSelectionLabel() {
return this._fakeSelectionLabel;
}

get data() {
return this._data;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I never liked this._data and was wondering why it is this way. I guess I will like to change it after more important subjects are dealt with.

@Reinmar
Copy link
Member

Reinmar commented Nov 9, 2017

Please don't merge this PR before we'll release alpha.2. It's a bit risky change.

@Reinmar
Copy link
Member

Reinmar commented Nov 16, 2017

Since alpha.2 is out, please conclude this PR and let us know when we could have a final check on it.

@jodator
Copy link
Contributor Author

jodator commented Nov 17, 2017

 * getRanges() {
	for ( const range of this._ranges ) {
		yield Range.createFromRange( range );
	}
}

Both in model.Selection and view.Selection?

Same with getFirstRange and getLastRange.

So probably directe getters are to be created as it would make no sense for getBlabla methods?

@scofalik
Copy link
Contributor

scofalik commented Nov 17, 2017

No matter what getRanges() will return, it should not let modify selection ranges without using selection API. So:

get ranges() {
    return this._ranges;
}

Is an incorrect solution if you ask me.

As for getFirstRange and getLastRange. Making it function get firstRange() { is "a bit risky" IMHO. Having it look like a property conceals the fact that the function does something more behind scenes (here iterating through all ranges in selection and comparing them). On the other hand, in 99% cases there would be just one range in selection. So I will be fine however those will end up.

@jodator
Copy link
Contributor Author

jodator commented Nov 17, 2017

@scofalik

return [ ...this._ranges ];

?

@scofalik
Copy link
Contributor

scofalik commented Nov 17, 2017

Technically it is fine. Although it was a generator function before, and some people on this project love their generators...

@jodator
Copy link
Contributor Author

jodator commented Nov 17, 2017

OK so what to do with those iterators?

I'll grab 🍿 and let you @pjasiun ⚔️ @scofalik fight over removing or not iterators :)

ps.: I'm for returning new array:

get ranges() {
    return [ ...this._ranges ];

to not mess with internal array.

@scofalik
Copy link
Contributor

I am for returning array, because in this case you always want to handle all of them. If not, there are already getFirstRange() and getLastRange() methods.

@pjasiun
Copy link

pjasiun commented Nov 20, 2017

I would go with:

get ranges() {
    return this. _ranges[ Symbol.iterator ]();
}

This way you will not create any new object and it will be still not mutable.

@jodator
Copy link
Contributor Author

jodator commented Nov 20, 2017

@scofalik I've done with all the suggested changes.

I've also removed Range.createFromRange() from getFirst/LastRange() and updated docs for those methods.

@@ -291,8 +311,11 @@ export default class Range {
ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) );
}

pos.path = pos.path.slice( 0, -1 );
pos.offset++;
const path = pos.getParentPath();
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is same as Position.createAfter( pos.parent )?

@@ -804,13 +839,14 @@ export default class Range {
// 4. At this moment we don't need the original range.
// We are going to modify the result and we need to return a new instance of Range.
// We have to create a copy of the reference range.
const result = new this( ref.start, ref.end );
let start = ref.start;
let end = ref.end;

// 5. Ranges should be checked and glued starting from the range that is closest to the reference range.
// Since ranges are sorted, start with the range with index that is closest to reference range index.
for ( let i = refIndex - 1; i >= 0; i++ ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder how the hell this works if the condition is i >= 0 and i is increment in each loop. Seems wrong.

* @member {Number} module:engine/view/position~Position#offset
*/
this.offset = offset;
this[ _parent ] = parent;
Copy link
Contributor

@scofalik scofalik Nov 24, 2017

Choose a reason for hiding this comment

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

Didn't we agree that this should be this._parent and this._offset?

* @member {module:engine/view/position~Position}
*/
this.end = end ? Position.createFromPosition( end ) : Position.createFromPosition( start );
this[ _start ] = start;
Copy link
Contributor

Choose a reason for hiding this comment

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

this._parent and this._offset

const mergePosition = mergeAttributes( breakStart );
range.start = mergePosition;
range.end = Position.createFromPosition( mergePosition );
mergeAttributes( breakStart );
Copy link
Contributor

@scofalik scofalik Nov 24, 2017

Choose a reason for hiding this comment

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

Note to self: check how mergeAttributes works now.

@scofalik
Copy link
Contributor

I've pushed some minor changes.

@scofalik
Copy link
Contributor

Hm, for some reason it broke: https://travis-ci.org/ckeditor/ckeditor5-engine/builds/306753449?utm_source=email&utm_medium=notification although tests pass for engine for me locally.

@scofalik scofalik merged commit 836dfd8 into master Nov 24, 2017
Reinmar added a commit that referenced this pull request Nov 24, 2017
This reverts commit 836dfd8, reversing
changes made to fc7da80.
@Reinmar
Copy link
Member

Reinmar commented Nov 24, 2017

I had to revert this change because it completely blows up editor tests.

Also, see https://github.com/ckeditor/ckeditor5-engine/issues/897#issuecomment-346862873.

@scofalik
Copy link
Contributor

What do you mean exactly by "blows up editor tests"? Performance issues or something else?

@Reinmar
Copy link
Member

Reinmar commented Nov 27, 2017

What do you mean exactly by "blows up editor tests"? Performance issues or something else?

Many failing tests in lists, images and undo.

jodator added a commit that referenced this pull request Nov 28, 2017
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.

Reduce memory pressure by stopping cloning all the time
5 participants