Skip to content

remove circular dependencies #2105

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

Open
wants to merge 1 commit into
base: 6.x
Choose a base branch
from

Conversation

iambumblehead
Copy link

@iambumblehead iambumblehead commented May 9, 2025

Removes circular dependencies by lazy-assigning some definitions in module "Universe.js"

Running the below benchmark many times, sometimes the newer version is faster and sometimes the older version is faster and its unclear if there is performance loss or gain...

This benchmark can be used with node's official benchmark.js scripts The last number is the rate of operations measured in ops/sec (higher is better).

bench.immutable.js
import common from '../node-v22.9.0/benchmark/common.js'
import Immutable6X from '../immutable-js6.X/dist/immutable.min.js'
import ImmutableNEW from '../immutable-js/dist/immutable.min.js'

const TYPE6XLISTPUSH = 'type-6.x.listpush'
const TYPE6XMAPBUILD = 'type-6.x.mapbuild'
const TYPENEWLISTPUSH = 'type-new.listpush'
const TYPENEWMAPBUILD = 'type-new.mapbuild'

const bench = common.createBenchmark(main, {
  n: [8000],
  type: [
    TYPE6XLISTPUSH,
    TYPE6XMAPBUILD,
    TYPENEWLISTPUSH,
    TYPENEWMAPBUILD
  ]
})

async function main(conf) {
  // save then return this obj to try bypassing any
  // runtime optimisation identifying this code as unused
  let list = ''

  const type = conf.type
  const n = conf.n

  if (type === TYPE6XLISTPUSH) {
    bench.start()
    list = Immutable6X.List().asMutable()
    for (let ii = 0; ii < n; ii++) {
      list = list.push(ii)
    }
    list = list.asImmutable()
    bench.end(conf.n)
  }

  if (type === TYPENEWLISTPUSH) {
    bench.start()
    list = ImmutableNEW.List().asMutable()
    for (let ii = 0; ii < n; ii++) {
      list = list.push(ii)
    }
    list = list.asImmutable()
    bench.end(conf.n)
  }

  if (type === TYPE6XMAPBUILD) {
    bench.start()
    let obj1024 = {}
    for (let ii = 0; ii < n; ii++) {
      obj1024['x' + ii] = ii
    }
    list = Immutable6X.Map(obj1024)
    bench.end(conf.n)
  }

  if (type === TYPENEWMAPBUILD) {
    bench.start()
    let obj1024 = {}
    for (let ii = 0; ii < n; ii++) {
      obj1024['x' + ii] = ii
    }
    list = Immutable6X.Map(obj1024)
    bench.end(conf.n)
  }

  return list
}
$ node ./bench.immutable.js 
bench.immutable.js type="type-6.x.listpush" n=8000: 710,371.819265781
bench.immutable.js type="type-6.x.mapbuild" n=8000: 356,138.6732766004
bench.immutable.js type="type-new.listpush" n=8000: 799,544.0999542061
bench.immutable.js type="type-new.mapbuild" n=8000: 348,305.6432523858

Copy link
Member

@jdeniau jdeniau left a comment

Choose a reason for hiding this comment

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

Thanks for this hard work ! It's really a nice to have to enable tree-shaking ! 🙏

Before reviewing this huge PR, I'd like to understand some things:

You did "move" some functions, like isListprobeIsList. Why did you do that ?
It seems that you did sort imports too. It's a nice to have, but it doesn't seems related to this PR.

Because it make the PR pretty hard to review (+7000 -6000 😨).

Could you undo some of those things to make the PR reviewable, to keep only the minimal changes ? (or for the sorting, open a PR that sort all imports and add an eslint rule that enforce sorted imports).

@jdeniau
Copy link
Member

jdeniau commented May 9, 2025

ping @bdurrer as you did work on that subject too.

@bdurrer
Copy link
Contributor

bdurrer commented May 9, 2025

do we have a test that verifies tree-shaken code still works? I remember that our first (failed) attempt looked good until webpack removed the magic.

@iambumblehead
Copy link
Author

You did "move" some functions, like isList → probeIsList. Why did you do that ?

Grouping things into a 'probe' module with prefixed 'probe'-naming enables one to intuit where probe-related behaviours are defined and to grep where they are imported. These things helped me to understand and change the sources.

It seems that you did sort imports too. [...] (or for the sorting, open a PR that sort all imports and add an eslint rule that enforce sorted imports).

Okay. Things were moved around without any awareness of order of imports. A separate PR could be made for it.

@iambumblehead
Copy link
Author

Feel free to leave this PR idle and use as inspiration or reference to solve the issue in some other way.

@iambumblehead
Copy link
Author

Changes continued here to export a functional interface similar to erlang's "map" https://www.erlang.org/docs/23/man/maps and the main benefit is smaller bundle size.

Using esbuild to bundle separate files that create and manipulate map-like data with immer, mutative, immutable.js and my own immutable-derived 'im' this is the result,

  • bench.mini.immer.min.js 9.1kb
  • bench.mini.mutative.min.js 18.8kb
  • bench.mini.immutable.min.js 65.9kb
  • bench.mini.im.min.js 6.7kb

My modified version of immutable is smallest. It could be smaller if time were spent further reducing size. Additionally, no functionality is lost; a file still exists which exports a chainable-methods-variant of Map and provides a choice to import that variant when needed.


Considerations for immutablejs based on my experience.

mergeDeep, updateIn, hasIn, removeIn etc could maybe be moved to a separately-imported package or module and removed as methods. If immutablejs' core interface can be used externally to support deep-manipulating behaviour (it can, imo) then these complex operations can be moved to "userspace", simplifying immutablejs' core. map4 = opMapsMerge(map1, map2, map3) Similarly, some niche-methods like flip() could maybe be moved to userland as well.

List and Map are probably the main types needed by users and all other types more niche (imo). If types like Record and Stack are moved to their own separated packages with some duplicated code, the overall impact is likely positive.

Using function calls rather than method calls internally would make refactoring and moving things around easier. For example the patch below would decouple Map from KeyedCollection

diff --git a/src/Map.js b/src/Map.js
index dd9faf5e4..2c1b411a9 100644
--- a/src/Map.js
+++ b/src/Map.js
@@ -37,11 +37,9 @@ export const Map = (value) =>
     ? emptyMap()
     : isMap(value) && !isOrdered(value)
       ? value
-      : emptyMap().withMutations((map) => {
-          const iter = KeyedCollection(value);
-          assertNotInfinite(iter.size);
-          iter.forEach((v, k) => map.set(k, v));
-        });
+      : opWithMutations(emptyMap(), m => {
+        Object.keys(value).forEach(k => opSet(m, k, value[k]))
+      })
 
 export class MapImpl extends KeyedCollectionImpl {
   create(value) {

Related to the above diff; consider exporting separate versions of constructors for different value types. eg, MapFromList, MapFromNativeMap, MapFromPlainObject and users can import and use specific (and comparatively simpler) variants they need.

Also consider removing or moving-out "atattransducer"-related things like this,

MapPrototype['@@transducer/init'] = MapPrototype.asMutable = asMutable;
MapPrototype['@@transducer/step'] = function (result, arr) {
  return result.set(arr[0], arr[1]);
};
MapPrototype['@@transducer/result'] = function (obj) {
  return obj.asImmutable();
};

Remove duplicated methods like map.merge and map.concat and support each behaviour through one method name only.

Basically these are the useful things to do now imo that would make immutablejs smaller and easier to refactor. Other changes would be useful as well, but those are the immediate things.

@jdeniau
Copy link
Member

jdeniau commented May 21, 2025

That's quite impressive dividing the size by 10! 🤯

About your suggestions:

Reducing the API

It might be interesting to reduce the size, but I don't want to reduce the API for now: I have no statistics about what methods are used or not.
Creating modules or separate packages might work for reducing the size, but it will split immutable in two: methods in immutable and methods outside. I think if you want to do that, you can choose a toolbox-approach library like lodash, no?

List and Map as first class

I think that you are right, List and Map are the two most useful classes here, but if tree-shaking works, it will automatically remove unused classes, don't you think ?

decouple Map from KeyedCollection

You use Object.keys in your example, but using KeyedCollection here is useful if you use the "array-of-tuple" constructor here: https://immutable-js.com/play/#TWFwKFtbJ2EnLCAnQUEnXSwgWydiJywgJ0JCJ11dKQ==

moving-out "atattransducer"

https://github.com/cognitect-labs/transducers-js has been archived in 2023, I think that we can remove that in v6 👍

About your PR

I have your review in mind, but I need to take time to review that.

If you can guide us about the global logic and how to apprehend this PR, it will help a lot as a lot of changes are relocating and renaming functions, which make the review quite harder to read.

@iambumblehead
Copy link
Author

iambumblehead commented May 22, 2025

Thanks for the thoughtful response. Seems agreeable. I will reply to some of those points only to clarify thoughts and not from disagreement.

[reducing the api] might be interesting to reduce the size, but [...] no statistics about what methods are used or not [...] it will split immutable in two: methods in immutable and methods outside

Consider removing the deep methods in a separate future release, after they have been supported with separately importable modules like this. These would be kept with core immutablejs sources and unit-tested with them,

import mergeMapsDeep from 'immutable/mergeMapsDeep'

The 'deep' manipulation functions are an area of challenge when refactoring immutablejs. Mainly this is because they create KeyedSequences, IndexedSequences and Maps and so lend themslves to circular conditions. If manipulations like 'mergeDeep' are imported separately from core immutablejs, the circular coupling issues go away and things easier to change.

if tree-shaking works, it will automatically remove unused classes, don't you think ?

Yes true. Each collection-type must be defined through its own mostly-separate import-tree, regardless of where it is defined.

You use Object.keys in your example, but using KeyedCollection here is useful if you use the "array-of-tuple"

Yes. If there were a MapFromLists like erlang's maps:from_list/1, a small function could be made for this specific condition. Small, tree-shakeable and also does not rely on KeyedCollection

const mapFromList = list => {
  utilAssertTupleShape(list)

  return opWithMutations(mapCreateEmpty(), map => {
    list.reduce((acc, kv) => opSet(acc, kv[0], kv[1]), map)
  })
}

I have your review in mind, but I need to take time to review that.

It may be impractical to review it. Large PRs are not great, but truly many layers of difficult changes are needed to remove circular dependencies here.

If you can guide us about the global logic and how to apprehend this PR

Removing the circular dependencies in controlled steps is not easy. The current immutablejs does not cooperate well with changes for various and surprising reasons. It is good to pace oneself.

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.

4 participants