Skip to content

Make EdgeHandler registration in SelectionCellsHandler optional and modular #890

Description

@tbouffard

Is your feature request related to a problem? Please describe.

Currently, all EdgeHandler implementations are always loaded, even if they are not used. This prevents proper tree-shaking and adds unnecessary weight to applications that don’t use all EdgeStyles.

EdgeHandlers are used to manage interactive behavior on selected edges. maxGraph provides 3 built-in handlers to match the main EdgeStyle implementations.

Historically, handler selection was based on:

  • the EdgeStyle implementation,
  • or a handlerKind category defined at registration (v0.20.0).

Historically, the creation logic for handlers lived in factory methods (create...Handler) on AbstractGraph. This had several drawbacks:

  • all handlers were always imported, even if unused (e.g. for visualization-only use cases),
  • customizing the handler meant subclassing both the handler and the factory methods.

Once #823 has been merged, those methods are removed from AbstractGraph, and tree-shaking is now possible when the SelectionCellsHandler plugin is not used.

Still, when the SelectionCellsHandler plugin is used, all EdgeHandler classes are declared and imported, even if some of the corresponding EdgeStyles are not registered or used.

This limits optimization, and we’d like to improve that further.


With tree-shaking enabled, only loading the necessary EdgeHandlers can decrease the bundle size.
The following figures are estimates done in #823:

Handlers used Decreased size
Only default EdgeHandler −7–8 kB
Only ElbowEdgeHandler −5 kB (inherits EdgeHandler)
Only SegmentEdgeHandler −0 kB (inherits Elbow)

Note

These numbers reflect the size reduction compared to the previous behavior, where all handlers were always bundled.


Where the coupling lives

Everything below reflects the state of the #823 branch, which is not merged yet.

The remaining coupling is a single field initializer in packages/core/src/view/plugin/SelectionCellsHandler.ts,
together with the three static imports it makes reachable:

private readonly edgeHandlerFactories = new Map<EdgeStyleHandlerKind, EdgeHandlerFactory>([
  ['default', (state: CellState) => new EdgeHandler(state)],
  ['elbow', (state: CellState) => new ElbowEdgeHandler(state)],
  ['segment', (state: CellState) => new EdgeSegmentHandler(state)],
]);

The import graph around it, checked on that branch:

  • Apart from the public barrel re-exports in index.ts, which are side-effect free and tree-shakeable, SelectionCellsHandler is the only module in packages/core/src that references the four handler classes at runtime. Nothing else has to be untangled: the change is confined to that one file.
  • The inheritance chain EdgeSegmentHandler → ElbowEdgeHandler → EdgeHandler is a real runtime dependency, which is what makes the savings in the table above asymmetric.
  • VertexHandler references EdgeHandler only in type positions, and declares it with import type. This is not a blocker: even as a value import TypeScript elided it, and lib/esm/view/handler/VertexHandler.js contains no EdgeHandler import either way. Noted so the edge is not mistaken for one that still has to be cut, nor credited with a bundle win it never produced.

Describe the solution you'd like

This issue originally covered three goals. Two of them are addressed by #823, so only the first is left for this issue:

Goal Status
Only load the handlers actually used Open — the subject of this issue
Customize handler selection without subclassing the class managing the selection Addressed in #823
Configure per Graph instance (local configuration, not global) Already the case

#823 adds SelectionCellsHandler.setEdgeHandlerFactory(handlerKind, factory) and setVertexHandlerFactory(factory), along with the EdgeHandlerFactory / VertexHandlerFactory types. edgeHandlerFactories is a private per-instance class field, so there is no state shared between graphs and no global registry involved.

The remaining problem is therefore narrower than originally stated: how the default map is populated, not how users override it. A minimal app that only uses the default connector should only load the default handler.

Note

The original description stated that "in the ts-example-selected-features, the SegmentEdgeHandler is unused but still included". That is not accurate: this example calls registerOrthogonalEdgeStyle() and uses edgeStyle: 'orthogonalEdgeStyle', which is registered with handlerKind: 'segment'. EdgeSegmentHandler is genuinely required there, and by inheritance so are ElbowEdgeHandler and EdgeHandler. Per the table above, that example would gain ~0 kB.
Demonstrating the win needs an example that registers SelectionCellsHandler and only styles whose handlerKind is 'default', or no edge style at all. js-example-selected-features already matches: it registers the plugin and no edge style, so all its edges resolve to the 'default' kind. No new example is needed.


Retained approach

The implementation follows the "Register handlers from the Graph constructor" alternative below, with one refinement: instead of Graph looking up the plugin and calling the setters after initialization, the factories are passed as a construction option.

  • SelectionCellsHandler keeps only the 'default' entry in its initial factory map.
  • BaseGraph accepts a new option edgeHandlerFactories?: Record<EdgeStyleHandlerKind, EdgeHandlerFactory>. When set, the AbstractGraph constructor forwards it to the SelectionCellsHandler plugin.
  • getDefaultEdgeHandlerFactories() is exported from its own module and returns a new Record holding the three built-in factories, following the naming of getDefaultPlugins(). Graph passes it in its constructor, so Graph users keep the current behavior.
  • Documented in packages/website/docs/usage/cell-handlers.md, so that BaseGraph users know how to opt in.

Compared to calling the setters after plugin initialization, the option keeps the configuration declarative and visible at construction time, and makes it reusable by BaseGraph users who want all three handlers without depending on Graph.

This introduces a breaking change for BaseGraph users who rely on elbow or segment edge styles: without the new option, those edges fall back to the default handler. It is documented in CHANGELOG.md.

A follow-up will generalize the wiring: an optional onConfigure(options) hook in the plugin lifecycle will let every plugin read the graph options, replacing the plugin-specific code introduced here by a generic call over all plugins implementing the hook.


Describe alternatives you've considered

Since the public registration API now exists in #823, the alternatives no longer weigh the same. They are kept below with that in mind.

✅ Register handlers from the Graph constructor

Let Graph be responsible for registering the handlers. It would look up the plugin and register default handlers after plugin initialization.

  • BaseGraph would not do this
  • this promotes composition over inheritance
  • preserves current default behavior when using Graph

This became the cheapest option: it reduces to shipping an edgeHandlerFactories map holding only 'default', and having Graph (or getDefaultPlugins()) call setEdgeHandlerFactory('elbow', …) and setEdgeHandlerFactory('segment', …).

Note

This would need to be clearly documented, since the behavior differs slightly from current defaults.


✅ Plugin with two variants

Have a base version of SelectionCellsHandler that only registers the default handler, and a second one (the current one) that registers all 3 handlers.

  • BaseSelectionCellsHandler: only registers EdgeHandler
  • SelectionCellsHandler: extends base and adds ElbowEdgeHandler and SegmentEdgeHandler

Warning

Both plugins would share the same plugin id for compatibility, but this may lead to confusion if misused.

Limitation: doesn’t support composability. Users can’t change the config of existing handlers without subclassing the plugin. Note that this limitation is largely lifted by the setters added in #823, which makes this variant carry more machinery than the problem now needs.


✅ Dedicated plugin for handler selection

Introduce EdgeHandlerSelectorPlugin that stores the handler mapping.

  • SelectionCellsHandler uses it internally to retrieve the correct handler
  • for default behavior, we include EdgeHandlerSelectorPlugin in the default plugin list

✅ Pros:

  • a single implementation of the selection plugin
  • flexible and composable
  • handler registration is explicit

⚠️ Cons:

  • adds a dependency between plugins
  • when using a custom plugin list, users must remember to include it
  • overall complexity isn’t lower than having two plugin variants, and higher than registering from the Graph constructor

❌ Global registry

Store the handler map globally, like existing registries.

Drawback: goes against the local configuration pattern we introduced for plugins. Would also introduce implicit behavior, harder to debug. With #823 this can be ruled out: it would regress the per-instance configuration that already exists.


Acceptance criteria

  • A BaseGraph application that registers SelectionCellsHandler and does not opt in no longer bundles ElbowEdgeHandler or EdgeSegmentHandler.
  • Graph keeps its current behavior: all three handler kinds available with no extra user setup, and the same bundle size as today.
  • The gain is measured and reported in the pull request, on js-example-selected-features, which demonstrates it without any change. No example is added: a dedicated one is not worth its maintenance cost.
  • ts-example-selected-features registers orthogonalEdgeStyle, whose handler kind is 'segment', so it has to declare that factory to keep the per segment handles on its edges. Updating it is part of this work: it is the migration every affected application has to do, and it keeps the continuity of its recorded bundle size.
  • The migration is documented in CHANGELOG.md, as BaseGraph users must opt in to the elbow/segment handlers.

Bundle baseline

Size of the @maxgraph/core chunk of each example, as measured in #823 with ./scripts/build-all-examples.bash:

Example before #823 after #823
js-example 468.70 kB 468.90 kB
js-example-selected-features 386.38 kB 386.54 kB
js-example-without-defaults 320.97 kB 240.03 kB
ts-example 428.51 kB 428.65 kB
ts-example-selected-features 361.38 kB 361.52 kB
ts-example-without-defaults 299.46 kB 220.87 kB

Only the without-defaults examples benefit from #823, as they are the ones that do not register SelectionCellsHandler. The four others grew by 0.14 to 0.20 kB, which is the cost of the factory map and its setters now held by the plugin. This issue targets those four, the applications that do register the plugin.

In #823, ts-example-without-defaults was the only example whose chunkSizeWarningLimit needed an update, from 300 to 221.


Additional context

This topic has been previously discussed:

It also relates to ongoing improvements for tree-shaking and plugin modularity.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions