Skip to content

Conversation

@smallsaucepan
Copy link
Member

@smallsaucepan smallsaucepan commented Jan 10, 2026

Addresses performance issues in these three packages introduced in v7.2.0.

Performance improvement summary:

function improvement
difference 20x to 130x
intersect 55x to 60x
union 10x to 120x

Requires a custom rollup config to support es5 minified output (clipper2-ts requires bigint, which needs to be polyfilled with the JSBI library before bundling). This should only be a temporary measure until we move to a more modern target on a major version release.

Inclusion of JSBI into minified output results in approx 40% bundle size increase. Feel performance is causing more pain than bundle size though, so still consider worth while. And as mentioned above, it won't be forever.

Adds a non-published "internal" module for shared code. Can be used for other packages as a way to avoid copy-pasting code.

Resolves #2851
Resolves #2833
Resolves #2955

Please provide the following when creating a PR:

  • Meaningful title, including the name of the package being modified.
  • Summary of the changes.
  • Heads up if this is a breaking change.
  • Any issues this resolves.
  • Inclusion of your details in the contributors field of package.json - you've earned it! 👏
  • Confirmation you've read the steps for preparing a pull request.

Some input test data was incorrectly wound so fixed those, as well as truncated input coords to more realistic 6 decimal places.
Split out some (hopefully) common code to a new, not to be published seperately turf-internals package.
Implementation currently implies limit of 6 decimal places, so may need to make this configurable / more generous before release.
…est data. Other issue specific test results adjusted.
…"linking" to the point @turf/turf could be built.
…th a non-default precision set).

Reverted corrections to incorrect windings in input test to make sure we handle improperly wound geojson.
Added runtime checks to force all windings to what clipper2 expects.
…rm misses.

Trying to get away with a minimal internal package e.g. don't have an aggregating "export all" index.ts. Hence a custom tsup to cater for submodule only importing.
…published package, and avoid having to add an exception case to packages/turf/test.ts to skip that directory.
@bratter
Copy link
Contributor

bratter commented Jan 10, 2026

This is awesome. Thank you!! It's going to help me for sure :-). Also, I guess the build size increase is before jsts is removed from the buffer change? Which will take the size back down again... then even better after a major version!

My one question is about the defensive winding checking. It certainly might be the best thing to do for robustness, BUT running Clipper2.AreaD on every input polygon adds quite a lot of overhead for a defensive transformation where if the polygon is correctly wound it will just work (note that Clipper and GeoJSON use the same winding direction). Given mfedderly's comments on defensive transformation in #2949, I am wondering if it is the right way to go. I do appreciate that if the tests passed before it wouldn't be possible to break them in a minor, but maybe at least worthwhile testing if rewind is more efficient that areaD?

IMHO, but feel free to ignore, the comments in the enforceOuterRing and enforceInnerRing functions are also a little unclear. I am almost certain (Per comment here: #2991 (comment)) that the winding order is the same. The reason why the rewind is needed in buffer is that the azimuthal transform in buffer flips the y which changes the winding order. The doc comment above those functions is implying the opposite.

The other question that this begs is should the test cases have improper winding? Several do at the moment, and some of them (@turf/union's not-overlapping test case for instance) even has one polygon wound one way and another the opposite in the same FeatureCollection.

@smallsaucepan
Copy link
Member Author

My one question is about the defensive winding checking ... adds quite a lot of overhead for a defensive transformation

Long term I'm in the same camp. Turf will be simpler to maintain if we don't have to cater to sloppy geojson. If it's not compliant we should push back on bug reports, similar to what we're starting to do with unrealistically precise coords. That said, best to wait for a major release to put in big red letters "we're not going to be holding your hand anymore".

maybe at least worthwhile testing if rewind is more efficient that areaD

Great idea. I'll compare them.

The other question that this begs is should the test cases have improper winding?

Probably not (unless that's something we're testing for like in a linter). It's probably something that crept in over time and we should avoid it going forward. Would be an interesting audit!

I am almost certain (Per comment here: #2991 (comment)) that the winding order is the same.

Will double check this also. I recall reading somewhere that clipper2-ts (at least for union, etc) didn't strictly care about the order so long as you were consistent within the same invocation.

{
"name": "@turf/internal",
"version": "7.3.1",
"private": true,
Copy link
Collaborator

Choose a reason for hiding this comment

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

https://docs.npmjs.com/cli/v11/configuring-npm/package-json#private

You can't depend on this from other packages if you mark it as private, they won't be able to be work because one of their dependencies will be missing.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ugh. Yes you're right, we can't drop tsup and keep this private. I think there's a valid need for a package where we can store shared code so will make public and document clearly it's not for direct usage, API may change at any time, etc.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@turf/clipper2-support or similar?

Copy link
Member Author

Choose a reason for hiding this comment

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

Makes more sense to me to keep it general purpose so we don't have to publish a new package for every bit of common code we want to share between packages.

import { PolyPathD, PathD, PathsD, PolyTreeD, areaD } from "clipper2-ts";
import { Polygon, MultiPolygon, Position } from "geojson";

const DEFAULT_PRECISION = 8;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This probably needs a rename to make it clear that this is for coordinates in lng, lat. If you project the coords into something with meters as their unit, you probably want 2 instead.

]
]
]
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Was this supposed to disappear? Did you check all of the output for visual sameness at least?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't use REGEN, so yes visually check and try to account for the differences. In this test (around since 2017) the removed results were three polygon slivers (effectively lines) that were being returned and causing errors downstream. They're regression tests for libraries we don't use any more, so would it be clearer to remove the test entirely?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sounds good about the extra feature removal. I guess with the isobands, isolines, and buffer changes I was just lucky enough to have the same geojson shape before and after, it didn't really change the structure just the coordinates. If you think these tests will help us regress Turf in the future I'm happy to keep them, or drop them otherwise.

I swear GitHub used to have a nice visual diff for .geojson files that we could use in PRs, but it seems to have disappeared. We desperately need better tooling for situations like this where all of the output changes but not in a meaningful way. Its so much easier to just look visually than try to reason about if two polygons switched order, or a ring got rotated around by a few points.

"properties": {
"fill-opacity": 0.5,
"fill": "#00F"
},
Copy link
Collaborator

Choose a reason for hiding this comment

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

Did you run prettier on the output geojson? I've been using pnpm prettier --write test/out/* test/in/* when I change test fixtures

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, at the editor level on save. And then again when lint-staged runs. Just ran manually then and didn't change anything. This is correct yes?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Interesting. I was surprised to see it go from multi-line to single line here. 🤷
My stuff doesn't run prettier on save at the moment and its driving me nuts.

[-85.42358073, 41.76885781]
]
]
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Another suspiciously deleted output

Copy link
Member Author

Choose a reason for hiding this comment

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

Same as 721-inverse above. A wafer thin polygon being returned by a previous clipping lib that would blow up downstream turf functions.

}

return path;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed that we should start telling the users to get the winding right before passing them in, but that's likely a major version issue.

function enforceOuterRing(path: PathD): PathD {
if (areaD(path) < 0) {
// Leave original array untouched.
return [...path].reverse();
Copy link
Collaborator

Choose a reason for hiding this comment

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

You should be able to mutate path instead, because ringToPath already creates your own internal representation.

return {
type: "MultiPolygon",
coordinates: polygons,
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

I kind of want to just always return MultiPolygon's even if there's only 1 polygon, but again... major version.
We still have to consider the case where we get 0 polygons back and whether that should be a MultiPolygon.

Copy link
Member Author

@smallsaucepan smallsaucepan Jan 13, 2026

Choose a reason for hiding this comment

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

Would make for a simpler interface. Plenty of options for what to return for 0, depending on how many liberties we're willing to take. MultiPolygon with empty coordinates array (not kosher from what I understand but used in the wild). Feature with null geometry (more correct, but back to where we started as far as return type conditional logic). Straight up null as we are now?

}

// Expectation is pools within islands within lakes within continents ...
// are handled as multipolygons. So further recursion is not required.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Have you tested this to make sure that it doesn't nest new outer rings inside holes?

if (first[0] !== last[0] || first[1] !== last[1]) {
coords.push([first[0], first[1]]);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this is fine, as long as we never do it with a LineString, but everything going through here should be Polygon rings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants