@rasamjs/core 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -0
  3. package/dist/changes/restore.js +54 -24
  4. package/dist/changes/restore.js.map +1 -1
  5. package/dist/changes/service.js +2 -2
  6. package/dist/changes/service.js.map +1 -1
  7. package/dist/changes/state.d.ts.map +1 -1
  8. package/dist/changes/state.js +33 -27
  9. package/dist/changes/state.js.map +1 -1
  10. package/dist/changes.d.ts.map +1 -1
  11. package/dist/changes.js +20 -4
  12. package/dist/changes.js.map +1 -1
  13. package/dist/components/decorators.d.ts.map +1 -1
  14. package/dist/components/decorators.js +4 -2
  15. package/dist/components/decorators.js.map +1 -1
  16. package/dist/services/claims.d.ts +7 -0
  17. package/dist/services/claims.d.ts.map +1 -0
  18. package/dist/services/claims.js +11 -0
  19. package/dist/services/claims.js.map +1 -0
  20. package/dist/services/registry.d.ts.map +1 -1
  21. package/dist/services/registry.js +2 -0
  22. package/dist/services/registry.js.map +1 -1
  23. package/package.json +157 -156
  24. package/reports/adoption.json +20 -20
  25. package/reports/animation-benchmark.json +612 -612
  26. package/reports/benchmark.json +45 -45
  27. package/reports/change-groups-benchmark.json +471 -0
  28. package/reports/creation.json +26 -26
  29. package/reports/interchange-benchmark.json +85 -85
  30. package/reports/phase-3-core-after.json +40 -0
  31. package/reports/phase-3-core-before.json +40 -0
  32. package/reports/public-api-contract.json +1 -0
  33. package/reports/size-components.json +3 -2
  34. package/reports/size-parity.json +9 -6
  35. package/reports/size.json +3 -2
  36. package/reports/svgjs-comparison.json +3104 -3104
  37. package/reports/svgjs-comparison.md +14 -14
  38. package/reports/svgjs-parity-current.json +43 -49
  39. package/src/changes/restore.ts +51 -32
  40. package/src/changes/service.ts +2 -2
  41. package/src/changes/state.ts +37 -28
  42. package/src/changes.ts +24 -5
  43. package/src/components/decorators.ts +4 -2
  44. package/src/services/claims.ts +14 -0
  45. package/src/services/registry.ts +2 -0
@@ -1,26 +1,26 @@
1
1
  # Rasam vs SVG.js
2
2
 
3
- SVG.js 3.2.8; Rasam 0.3.0 workspace. CPU: Intel(R) Core(TM) i7-10610U CPU @ 1.80GHz.
3
+ SVG.js 3.2.8; Rasam 0.3.1 workspace. CPU: Intel(R) Core(TM) i7-10610U CPU @ 1.80GHz.
4
4
 
5
5
  All timings below are medians in milliseconds. Ratios above 1 mean Rasam took longer.
6
6
 
7
7
  | Browser | Workload | Nodes | Samples | Rasam | SVG.js | Rasam / SVG.js |
8
8
  | --- | --- | ---: | ---: | ---: | ---: | ---: |
9
- | chromium 156.0.8078.4 | creation | 2000 | 15 | 9.40 | 11.70 | 0.80 |
10
- | chromium 156.0.8078.4 | creation | 8000 | 15 | 36.70 | 39.10 | 0.94 |
11
- | chromium 156.0.8078.4 | fill | 1000 | 100 | 0.47 | 1.30 | 0.36 |
12
- | chromium 156.0.8078.4 | repeated-fill | 1000 | 100 | 0.71 | 3.51 | 0.20 |
13
- | chromium 156.0.8078.4 | pointer-one | 8000 | 40 | 3.82 | 3.66 | 1.04 |
14
- | chromium 156.0.8078.4 | pointer-all | 8000 | 40 | 3.99 | 3.81 | 1.05 |
15
- | firefox 157.0 | creation | 2000 | 15 | 19.00 | 21.00 | 0.90 |
16
- | firefox 157.0 | creation | 8000 | 15 | 92.00 | 86.00 | 1.07 |
17
- | firefox 157.0 | fill | 1000 | 100 | 1.20 | 1.50 | 0.80 |
18
- | firefox 157.0 | repeated-fill | 1000 | 100 | 1.70 | 4.90 | 0.35 |
9
+ | chromium 156.0.8078.4 | creation | 2000 | 15 | 9.50 | 10.90 | 0.87 |
10
+ | chromium 156.0.8078.4 | creation | 8000 | 15 | 37.80 | 40.00 | 0.95 |
11
+ | chromium 156.0.8078.4 | fill | 1000 | 100 | 0.49 | 1.31 | 0.37 |
12
+ | chromium 156.0.8078.4 | repeated-fill | 1000 | 100 | 0.70 | 3.56 | 0.20 |
13
+ | chromium 156.0.8078.4 | pointer-one | 8000 | 40 | 3.87 | 3.68 | 1.05 |
14
+ | chromium 156.0.8078.4 | pointer-all | 8000 | 40 | 3.83 | 3.66 | 1.05 |
15
+ | firefox 157.0 | creation | 2000 | 15 | 23.00 | 21.00 | 1.10 |
16
+ | firefox 157.0 | creation | 8000 | 15 | 91.00 | 82.00 | 1.11 |
17
+ | firefox 157.0 | fill | 1000 | 100 | 1.30 | 1.50 | 0.87 |
18
+ | firefox 157.0 | repeated-fill | 1000 | 100 | 1.60 | 4.50 | 0.36 |
19
19
  | firefox 157.0 | pointer-one | 8000 | 40 | 4.20 | 3.40 | 1.24 |
20
20
  | firefox 157.0 | pointer-all | 8000 | 40 | 4.00 | 3.40 | 1.18 |
21
- | webkit 27.2 | creation | 2000 | 15 | 13.00 | 18.00 | 0.72 |
22
- | webkit 27.2 | creation | 8000 | 15 | 66.00 | 84.00 | 0.79 |
23
- | webkit 27.2 | fill | 1000 | 100 | 0.70 | 1.70 | 0.41 |
21
+ | webkit 27.2 | creation | 2000 | 15 | 14.00 | 18.00 | 0.78 |
22
+ | webkit 27.2 | creation | 8000 | 15 | 58.00 | 73.00 | 0.79 |
23
+ | webkit 27.2 | fill | 1000 | 100 | 0.70 | 1.60 | 0.44 |
24
24
  | webkit 27.2 | repeated-fill | 1000 | 100 | 0.90 | 4.90 | 0.18 |
25
25
  | webkit 27.2 | pointer-one | 8000 | 40 | 4.20 | 3.40 | 1.24 |
26
26
  | webkit 27.2 | pointer-all | 8000 | 40 | 4.20 | 3.40 | 1.24 |
@@ -1,31 +1,23 @@
1
1
  {
2
- "generatedAt": "2026-11-07T18:31:24.666Z",
3
- "revision": "8199249715ac19aaa2d8a287b748d8c29d0eceb9",
2
+ "generatedAt": "2026-11-09T00:19:11.349Z",
3
+ "revision": "124aaed60d894f51033904e9959bbf4286d0f3e9",
4
4
  "dirtyTree": [
5
- "M apps/circuit/package.json",
6
- " M apps/playground/package.json",
7
- " M apps/react-example/package.json",
8
- " M bun.lock",
9
- " M packages/boolean/package.json",
10
- " M packages/clipboard/package.json",
11
- " M packages/connectors/API.md",
12
- " M packages/connectors/CHANGELOG.md",
13
- " M packages/connectors/README.md",
14
- " M packages/connectors/RELEASING.md",
15
- " M packages/connectors/package.json",
16
- " M packages/editor/package.json",
17
- " M packages/history/package.json",
18
- " M packages/rasam/CHANGELOG.md",
19
- " M packages/rasam/RELEASING.md",
20
- " M packages/rasam/package.json",
5
+ "M packages/editor/reports/release-readiness.json",
6
+ " M packages/history/reports/history-performance.json",
7
+ " M packages/history/reports/release-readiness.json",
21
8
  " M packages/rasam/reports/adoption.json",
9
+ " M packages/rasam/reports/animation-benchmark.json",
10
+ " M packages/rasam/reports/benchmark.json",
22
11
  " M packages/rasam/reports/creation.json",
23
12
  " M packages/rasam/reports/interchange-benchmark.json",
24
- " M packages/react/package.json",
25
- " M packages/selection/package.json",
26
- " M packages/tools/package.json",
27
- " M packages/transformer/package.json",
28
- " M packages/viewport/package.json"
13
+ " M packages/rasam/reports/release-readiness.json",
14
+ " M packages/rasam/reports/svgjs-comparison.json",
15
+ " M packages/rasam/reports/svgjs-comparison.md",
16
+ " M packages/rasam/reports/svgjs-parity-current.json",
17
+ " M packages/selection/reports/marquee-benchmark.json",
18
+ " M packages/selection/reports/release-readiness.json",
19
+ " M packages/tools/reports/drag-benchmark.json",
20
+ " M packages/tools/reports/release-readiness.json"
29
21
  ],
30
22
  "sourceFiles": [
31
23
  {
@@ -98,7 +90,7 @@
98
90
  },
99
91
  {
100
92
  "path": "src/changes.ts",
101
- "sha256": "3c1270b193852d88350c84c66453bba1601a4ec6da8263c20e7a4a482982738b"
93
+ "sha256": "5195b06c773565fb7fcc79b14249eaef931e0fa79d0f7631f5ad1bec50a7f6af"
102
94
  },
103
95
  {
104
96
  "path": "src/changes/derived.ts",
@@ -110,15 +102,15 @@
110
102
  },
111
103
  {
112
104
  "path": "src/changes/restore.ts",
113
- "sha256": "6ccbe21c649e6e71f32a2d75b8df581d1bd011009b1d203a1e214d0195d272b6"
105
+ "sha256": "f2f3f749888b62b44a0667a7141b72786d488622740882841a6f5f08be8e3a9a"
114
106
  },
115
107
  {
116
108
  "path": "src/changes/service.ts",
117
- "sha256": "c6cab7009ee7ccd49442b57aed253b495266d8ca43ae6aaf834c1413ac01a215"
109
+ "sha256": "0384e84ab0fcaef38f67bd2167c498e1188fc639ef23f94aeed45e3895c606a5"
118
110
  },
119
111
  {
120
112
  "path": "src/changes/state.ts",
121
- "sha256": "3f190a0dde69ec33d96d1dc796836ce13094ea1afa574855e818aefd17c1b7b3"
113
+ "sha256": "889ace35fb6ca0ddb11960c43e24b64bfb96697165b8d9ea5006aae99b0ec18a"
122
114
  },
123
115
  {
124
116
  "path": "src/changes/types.ts",
@@ -138,7 +130,7 @@
138
130
  },
139
131
  {
140
132
  "path": "src/components/decorators.ts",
141
- "sha256": "abf31eeb3fad5251c87ef7e9888d1016c61037e3ad97faf8e35c9814b0c72717"
133
+ "sha256": "5a94b137073c4f41b7bdea53871b9b8270aa731bfe49963b3209717b35673937"
142
134
  },
143
135
  {
144
136
  "path": "src/components/metadata.ts",
@@ -612,6 +604,10 @@
612
604
  "path": "src/services/catalog.ts",
613
605
  "sha256": "bd11868233bb9604062311cff9bfbacf2cf93e054dd979b5c0853e3b439ad7b2"
614
606
  },
607
+ {
608
+ "path": "src/services/claims.ts",
609
+ "sha256": "b8db85df1c0f0806d30b6caccf20f1a94d14c2975a01b11e2ef5fa411080c817"
610
+ },
615
611
  {
616
612
  "path": "src/services/context.ts",
617
613
  "sha256": "3d86040e3a601321d4811b17f1063a0e181e3d6362918a6d0bf298da19c0f72f"
@@ -638,7 +634,7 @@
638
634
  },
639
635
  {
640
636
  "path": "src/services/registry.ts",
641
- "sha256": "7f3aa78852eb8b9d9affadff346ee15d99f5d158891fb68561f6f3da23c4cda9"
637
+ "sha256": "a6ad5e6fcc646b10349c29ed0163863b958892e2a008c033eec18fd658a53f1a"
642
638
  },
643
639
  {
644
640
  "path": "src/services/scheduler.ts",
@@ -742,7 +738,7 @@
742
738
  },
743
739
  {
744
740
  "path": "../../bun.lock",
745
- "sha256": "d33d090542d5ea14545f4da707e6077c1552d615bb49151fe20dd0d355000a54"
741
+ "sha256": "b3789d00332812b09c75e008611f9bd5c7721c296c8a883a3c7a71cde94d3193"
746
742
  }
747
743
  ],
748
744
  "svgjs": "3.2.8",
@@ -9722,8 +9718,8 @@
9722
9718
  },
9723
9719
  "size": {
9724
9720
  "fixture": "fixtures/size.ts",
9725
- "rawBytes": 68399,
9726
- "gzipBytes": 20540,
9721
+ "rawBytes": 68463,
9722
+ "gzipBytes": 20572,
9727
9723
  "budgetBytes": 20992,
9728
9724
  "included": [
9729
9725
  "src/changes/mutation.ts",
@@ -9762,6 +9758,7 @@
9762
9758
  "src/core/native-validation.ts",
9763
9759
  "src/services/element-key.ts",
9764
9760
  "src/services/initialization.ts",
9761
+ "src/services/claims.ts",
9765
9762
  "src/services/registry.ts",
9766
9763
  "src/services/scheduler.ts",
9767
9764
  "src/changes/service.ts",
@@ -9775,7 +9772,7 @@
9775
9772
  },
9776
9773
  "performance": {
9777
9774
  "fixture": "scripts/benchmark.mjs",
9778
- "date": "2026-11-07T18:31:19.500Z",
9775
+ "date": "2026-11-09T00:19:06.376Z",
9779
9776
  "browser": "Chromium 156.0.8078.4",
9780
9777
  "node": "v24.21.0",
9781
9778
  "os": "win32 10.0.26200",
@@ -9784,30 +9781,27 @@
9784
9781
  "count": 1000,
9785
9782
  "warmup": 20,
9786
9783
  "iterations": 100,
9787
- "medianMs": 0.6999999284744263,
9788
- "p95Ms": 1.3000000715255737,
9784
+ "medianMs": 0.5,
9785
+ "p95Ms": 0.7999999523162842,
9789
9786
  "targetMedianMs": 16,
9790
9787
  "includes": "logical mutations and synchronous DOM commit; excludes paint",
9791
9788
  "timings": [
9792
- 0.40000009536743164, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
9793
- 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5999999046325684, 0.5999999046325684,
9789
+ 0.3999999761581421, 0.3999999761581421, 0.3999999761581421, 0.3999999761581421,
9790
+ 0.40000009536743164, 0.40000009536743164, 0.40000009536743164, 0.40000009536743164, 0.5, 0.5,
9791
+ 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
9792
+ 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
9793
+ 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5999999046325684, 0.5999999046325684, 0.5999999046325684,
9794
9794
  0.5999999046325684, 0.5999999046325684, 0.5999999046325684, 0.5999999046325684,
9795
- 0.5999999046325684, 0.6000000238418579, 0.6000000238418579, 0.6000000238418579,
9795
+ 0.5999999046325684, 0.5999999046325684, 0.6000000238418579, 0.6000000238418579,
9796
+ 0.6000000238418579, 0.6000000238418579, 0.6000000238418579, 0.6000000238418579,
9797
+ 0.6000000238418579, 0.6000000238418579, 0.6000000238418579, 0.6000000238418579,
9796
9798
  0.6000000238418579, 0.6000000238418579, 0.6000000238418579, 0.6000000238418579,
9797
9799
  0.6000000238418579, 0.6000000238418579, 0.6000000238418579, 0.6000000238418579,
9798
- 0.6000000238418579, 0.6000000238418579, 0.6000000238418579, 0.6999999284744263,
9799
- 0.6999999284744263, 0.6999999284744263, 0.6999999284744263, 0.6999999284744263,
9800
- 0.6999999284744263, 0.6999999284744263, 0.6999999284744263, 0.7000000476837158,
9800
+ 0.6000000238418579, 0.6000000238418579, 0.6999999284744263, 0.6999999284744263,
9801
9801
  0.7000000476837158, 0.7000000476837158, 0.7000000476837158, 0.7000000476837158,
9802
9802
  0.7000000476837158, 0.7000000476837158, 0.7000000476837158, 0.7000000476837158,
9803
- 0.7000000476837158, 0.7000000476837158, 0.7000000476837158, 0.7999999523162842,
9804
- 0.8000000715255737, 0.8000000715255737, 0.8000000715255737, 0.8999999761581421,
9805
- 0.8999999761581421, 0.8999999761581421, 0.8999999761581421, 0.9000000953674316, 1, 1, 1, 1,
9806
- 1.100000023841858, 1.100000023841858, 1.100000023841858, 1.100000023841858, 1.100000023841858,
9807
- 1.1999999284744263, 1.1999999284744263, 1.1999999284744263, 1.1999999284744263,
9808
- 1.1999999284744263, 1.2000000476837158, 1.2000000476837158, 1.2000000476837158,
9809
- 1.2000000476837158, 1.2999999523162842, 1.2999999523162842, 1.2999999523162842,
9810
- 1.3000000715255737, 1.3000000715255737, 1.399999976158142, 1.5, 1.5, 1.600000023841858
9803
+ 0.7000000476837158, 0.7999999523162842, 0.7999999523162842, 0.7999999523162842,
9804
+ 0.8000000715255737, 0.8999999761581421, 1.2999999523162842
9811
9805
  ]
9812
9806
  }
9813
9807
  }
@@ -68,8 +68,8 @@ function applyStateChange(
68
68
  },
69
69
  };
70
70
  }
71
- if (change.domain === 'bounds') return { ...snapshot, contributesToBounds: value as boolean };
72
- return { ...snapshot, [change.domain]: value };
71
+ const field = change.domain === 'bounds' ? 'contributesToBounds' : change.domain;
72
+ return { ...snapshot, [field]: value };
73
73
  }
74
74
 
75
75
  /** Validates the whole patch before writing; rolls back logical state on failure. */
@@ -186,18 +186,37 @@ function validateGraph(
186
186
  ): void {
187
187
  const context = owner.context;
188
188
  const removed = new Set([...states].filter(([, state]) => !state).map(([target]) => target.node));
189
+ let ids: Map<unknown, AnyNode | null> | undefined;
190
+ const siblingCounts = new Map<NodeHandle, number>();
191
+ for (const state of states.values()) {
192
+ const parent = state?.placement.parent;
193
+ if (!parent) continue;
194
+ let count = siblingCounts.get(parent);
195
+ if (count === undefined) {
196
+ count = 0;
197
+ if (!parent.node.isDestroyed && 'children' in parent.node)
198
+ for (const node of (parent.node as ParentNode).children)
199
+ if (!states.has(context.changes.handle(node))) count++;
200
+ }
201
+ siblingCounts.set(parent, count + 1);
202
+ }
189
203
  for (const [target, state] of states) {
190
204
  if (!state) continue;
191
205
  if (context.registry.registered(state.constructor) !== state.constructor)
192
206
  throw new ChangeConflictError(`Constructor is not registered: ${state.constructor.tag}`);
193
207
  const id = state.properties.id?.value;
194
- if (
195
- id !== undefined &&
196
- context.registry.nodes.some(
197
- (node) => node !== target.node && !removed.has(node) && node.getAttributeValue('id') === id,
198
- )
199
- )
200
- throw new ChangeConflictError(`SVG ID is already in use: ${id}`);
208
+ if (id !== undefined) {
209
+ if (!ids) {
210
+ ids = new Map();
211
+ for (const node of context.registry.nodes) {
212
+ const value = node.getAttributeValue('id');
213
+ if (value !== undefined && !removed.has(node))
214
+ ids.set(value, ids.has(value) ? null : node);
215
+ }
216
+ }
217
+ if (ids.has(id) && ids.get(id) !== target.node)
218
+ throw new ChangeConflictError(`SVG ID is already in use: ${id}`);
219
+ }
201
220
  if (!target.node.isDestroyed) {
202
221
  for (const [attribute, property] of Object.entries(state.properties))
203
222
  target.node.validateAttribute(attribute, property.value);
@@ -221,22 +240,10 @@ function validateGraph(
221
240
  throw new ChangeConflictError('The reference value and its planned target ID disagree');
222
241
  }
223
242
  if (state.placement.parent) {
224
- const parent = state.placement.parent;
225
- const existing =
226
- !parent.node.isDestroyed && 'children' in parent.node
227
- ? (parent.node as ParentNode).children.map((node) => context.changes.handle(node))
228
- : [];
229
- const children = new Set(
230
- existing.filter(
231
- (child) => !states.has(child) || states.get(child)?.placement.parent === parent,
232
- ),
233
- );
234
- for (const [child, planned] of states)
235
- if (planned?.placement.parent === parent) children.add(child);
236
243
  if (
237
244
  !Number.isInteger(state.placement.index) ||
238
245
  state.placement.index < 0 ||
239
- state.placement.index >= children.size
246
+ state.placement.index >= siblingCounts.get(state.placement.parent)!
240
247
  )
241
248
  throw new ChangeConflictError('The history sibling position is no longer available');
242
249
  }
@@ -342,23 +349,35 @@ function applyStates(
342
349
  domains.get(target)!.has('existence')),
343
350
  );
344
351
  for (const [target] of placements) if (target.node.parent) target.node.remove();
345
- const pending = [...placements];
346
- while (pending.length) {
347
- const ready = pending.filter(
348
- ([, state]) =>
349
- !state!.placement.parent || !pending.some(([target]) => target === state!.placement.parent),
352
+ const positions = new Map(placements.map(([target], index) => [target, index]));
353
+ const dependants = new Map<NodeHandle, typeof placements>();
354
+ let ready: typeof placements = [];
355
+ for (const entry of placements) {
356
+ const parent = entry[1]!.placement.parent;
357
+ if (!parent || !positions.has(parent)) ready.push(entry);
358
+ else {
359
+ let children = dependants.get(parent);
360
+ if (!children) dependants.set(parent, (children = []));
361
+ children.push(entry);
362
+ }
363
+ }
364
+ // Preflight has already rejected parent cycles.
365
+ while (ready.length) {
366
+ ready.sort(
367
+ (a, b) =>
368
+ a[1]!.placement.index - b[1]!.placement.index ||
369
+ positions.get(a[0])! - positions.get(b[0])!,
350
370
  );
351
- if (!ready.length) throw new ChangeConflictError('Cyclic placements');
352
- ready.sort((a, b) => a[1]!.placement.index - b[1]!.placement.index);
353
- for (const entry of ready) {
354
- const [target, state] = entry;
371
+ const next: typeof placements = [];
372
+ for (const [target, state] of ready) {
355
373
  const parent = state!.placement.parent?.node as ParentNode | undefined;
356
374
  if (parent) {
357
375
  parent[validateChild](target.node);
358
376
  parent.add(target.node, Math.min(state!.placement.index, parent.children.length));
359
377
  }
360
- pending.splice(pending.indexOf(entry), 1);
378
+ for (const child of dependants.get(target) ?? []) next.push(child);
361
379
  }
380
+ ready = next;
362
381
  }
363
382
  // Parent order is authoritative across compound edits and separate edit callbacks.
364
383
  for (const [target, state] of states)
@@ -405,8 +405,7 @@ export class ChangeService {
405
405
  this.delivering = true;
406
406
  const failures: unknown[] = [];
407
407
  try {
408
- while (this.queue.length) {
409
- const next = this.queue.shift()!;
408
+ for (const next of this.queue) {
410
409
  for (const listener of [...this.listeners]) {
411
410
  if (!this.listeners.has(listener)) continue;
412
411
  try {
@@ -417,6 +416,7 @@ export class ChangeService {
417
416
  }
418
417
  }
419
418
  } finally {
419
+ this.queue.length = 0;
420
420
  this.delivering = false;
421
421
  }
422
422
  if (failures.length === 1 && failures[0] instanceof ChangeConflictError) throw failures[0];
@@ -93,25 +93,27 @@ export function diffState(
93
93
  if (!sameState(previous, value))
94
94
  changes.push({ target, domain: 'property', attribute, before: previous, after: value });
95
95
  }
96
- if (transform && before.transform && after.transform)
97
- changes.push({ target, domain: 'transform', before: before.transform, after: after.transform });
98
- if (!sameState(before.content, after.content) && before.content && after.content)
99
- changes.push({ target, domain: 'content', before: before.content, after: after.content });
100
- if (!sameState(before.presentation, after.presentation))
101
- changes.push({
102
- target,
103
- domain: 'presentation',
104
- before: before.presentation,
105
- after: after.presentation,
106
- });
107
- if (!sameState(before.custom, after.custom))
108
- changes.push({ target, domain: 'custom', before: before.custom, after: after.custom });
109
- if (!sameState(before.native, after.native) && before.native && after.native)
110
- changes.push({ target, domain: 'native', before: before.native, after: after.native });
111
- if (!sameState(before.placement, after.placement))
112
- changes.push({ target, domain: 'placement', before: before.placement, after: after.placement });
113
- if (!sameState(before.children, after.children) && before.children && after.children)
114
- changes.push({ target, domain: 'children', before: before.children, after: after.children });
96
+ for (const domain of [
97
+ 'transform',
98
+ 'content',
99
+ 'presentation',
100
+ 'custom',
101
+ 'native',
102
+ 'placement',
103
+ 'children',
104
+ ] as const) {
105
+ const previous = before[domain],
106
+ value = after[domain];
107
+ if (
108
+ domain !== 'presentation' &&
109
+ domain !== 'custom' &&
110
+ domain !== 'placement' &&
111
+ (!previous || !value)
112
+ )
113
+ continue;
114
+ if (domain === 'transform' ? transform : !sameState(previous, value))
115
+ changes.push({ target, domain, before: previous, after: value } as ModelChange);
116
+ }
115
117
  if (before.contributesToBounds !== after.contributesToBounds)
116
118
  changes.push({
117
119
  target,
@@ -138,7 +140,7 @@ export function combineChanges(changes: readonly ModelChange[]): readonly ModelC
138
140
  for (const [target, records] of lifecycle) {
139
141
  const first = records.findIndex((change) => change.domain === 'existence');
140
142
  if (first < 0) {
141
- normalized.push(...records);
143
+ for (const record of records) normalized.push(record);
142
144
  continue;
143
145
  }
144
146
  let last = records.length - 1;
@@ -160,14 +162,22 @@ export function combineChanges(changes: readonly ModelChange[]): readonly ModelC
160
162
  }
161
163
  const orders = new Map<
162
164
  NodeHandle,
163
- { before: readonly NodeHandle[]; after: readonly NodeHandle[] }
165
+ { before: ReadonlyMap<NodeHandle, number>; after: ReadonlyMap<NodeHandle, number> }
164
166
  >();
167
+ const indices = (children: readonly NodeHandle[]) => {
168
+ const positions = new Map<NodeHandle, number>();
169
+ // Reverse traversal retains indexOf's first match even for repeated handles.
170
+ for (let index = children.length - 1; index >= 0; index--)
171
+ positions.set(children[index], index);
172
+ return positions;
173
+ };
165
174
  for (const change of merged.values()) {
166
- if (change.domain === 'children') orders.set(change.target, change);
175
+ if (change.domain === 'children')
176
+ orders.set(change.target, { before: indices(change.before), after: indices(change.after) });
167
177
  if (change.domain === 'existence' && (change.before?.children || change.after?.children))
168
178
  orders.set(change.target, {
169
- before: change.before?.children ?? [],
170
- after: change.after?.children ?? [],
179
+ before: indices(change.before?.children ?? []),
180
+ after: indices(change.after?.children ?? []),
171
181
  });
172
182
  }
173
183
  const placement = (
@@ -176,7 +186,7 @@ export function combineChanges(changes: readonly ModelChange[]): readonly ModelC
176
186
  edge: 'before' | 'after',
177
187
  ) => {
178
188
  const siblings = value.parent ? orders.get(value.parent)?.[edge] : undefined;
179
- return siblings ? { ...value, index: siblings.indexOf(target) } : value;
189
+ return siblings ? { ...value, index: siblings.get(target) ?? -1 } : value;
180
190
  };
181
191
  return Object.freeze(
182
192
  [...merged.values()]
@@ -234,9 +244,8 @@ function patchSnapshot(
234
244
  if (transform.transform !== null) properties.transform = { value: transform.transform };
235
245
  return immutable({ ...snapshot, properties, transform });
236
246
  }
237
- if (change.domain === 'bounds')
238
- return immutable({ ...snapshot, contributesToBounds: change[edge] });
239
- return immutable({ ...snapshot, [change.domain]: change[edge] });
247
+ const field = change.domain === 'bounds' ? 'contributesToBounds' : change.domain;
248
+ return immutable({ ...snapshot, [field]: change[edge] });
240
249
  }
241
250
 
242
251
  export const stateAdapter = Object.freeze({ capture: captureState, diff: diffState, immutable });
package/src/changes.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { AnyNode } from './core/types.js';
2
- import type { ChangeEvent, ChangeGroup, ChangeMetadata, ModelChange } from './changes/types.js';
2
+ import type {
3
+ ChangeEvent,
4
+ ChangeGroup,
5
+ ChangeMetadata,
6
+ ModelChange,
7
+ NodeHandle,
8
+ } from './changes/types.js';
3
9
  import { ChangeConflictError, ChangeObserverError } from './changes/types.js';
4
10
  import { combineChanges, stateAdapter } from './changes/state.js';
5
11
  import { restoreChanges } from './changes/restore.js';
@@ -64,14 +70,25 @@ export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}
64
70
  drawing.assertLive();
65
71
  const owner = drawing.context.root!;
66
72
  let id: number | undefined;
67
- let changes: readonly ModelChange[] = [];
73
+ let changes: ModelChange[] = [];
74
+ const targets = new Set<NodeHandle>();
68
75
  let invalidated = false;
69
76
  // Subscribe before groupstart so edits made by start observers are captured too.
70
77
  const unsubscribe = observeChanges(drawing, (event) => {
71
78
  if (event.type === 'dispose') invalidated = true;
72
79
  if (id === undefined && event.type === 'groupstart') id = event.group;
73
- if (event.type === 'change' && event.group === id && !event.temporary)
74
- changes = combineChanges([...changes, ...event.changes]);
80
+ if (event.type !== 'change') return;
81
+ if (event.group === id && !event.temporary) {
82
+ for (const change of event.changes) {
83
+ changes.push(change);
84
+ targets.add(change.target);
85
+ }
86
+ } else if (event.changes.some((change) => targets.has(change.target))) {
87
+ // A net-zero field must release its old baseline before an outside edit.
88
+ changes = [...combineChanges(changes)];
89
+ targets.clear();
90
+ for (const change of changes) targets.add(change.target);
91
+ }
75
92
  });
76
93
  let group: ReturnType<typeof drawing.context.changes.group>;
77
94
  try {
@@ -91,6 +108,7 @@ export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}
91
108
  const finish = (cancelled: boolean, restored = false) => {
92
109
  active = false;
93
110
  changes = [];
111
+ targets.clear();
94
112
  unsubscribe();
95
113
  group.end(cancelled, restored);
96
114
  };
@@ -117,7 +135,8 @@ export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}
117
135
  const failures: unknown[] = [];
118
136
  try {
119
137
  try {
120
- if (changes.length) restoreChanges(owner, changes, 'undo');
138
+ const combined = combineChanges(changes);
139
+ if (combined.length) restoreChanges(owner, combined, 'undo');
121
140
  } catch (error) {
122
141
  if (error instanceof ChangeObserverError) failures.push(error);
123
142
  else throw error;
@@ -8,6 +8,7 @@ import {
8
8
  import { defineComponent } from './metadata.js';
9
9
  import { initializeElement } from '../services/initialization.js';
10
10
  import { destroySubtrees } from '../core/destruction.js';
11
+ import { claims, claimedSince } from '../services/claims.js';
11
12
  import { withInternalStructure } from '../mixins/parent-internals.js';
12
13
  import type { ComponentClass, ComponentOptions, PropOptions } from './types.js';
13
14
 
@@ -21,13 +22,14 @@ export function component(options: ComponentOptions) {
21
22
  const Base = target as ComponentClass;
22
23
  const Decorated = class extends Base {
23
24
  constructor(...args: ConstructorParameters<ComponentClass>) {
25
+ // A marker, not a copy of every node: construction runs once per component.
24
26
  const context = args[0],
25
- before = new Set(context.registry.nodes);
27
+ mark = claims;
26
28
  try {
27
29
  super(...args);
28
30
  if (new.target === Decorated && !this.adopting) this[initializeElement]();
29
31
  } catch (error) {
30
- const created = context.registry.nodes.filter((node) => !before.has(node));
32
+ const created = claimedSince(context.registry.nodes, mark);
31
33
  if (created.length) {
32
34
  try {
33
35
  withInternalStructure(() => created[0][destroySubtrees](created));
@@ -0,0 +1,14 @@
1
+ import type { AnyNode } from '../core/types.js';
2
+
3
+ /** Internal claim order. A node keeps the number from its first claim, even if reclaimed. */
4
+ const claimOrder = new WeakMap<AnyNode, number>();
5
+ /** Claims so far: a constant-time marker for `claimedSince`, instead of copying every node. */
6
+ export let claims = 0;
7
+
8
+ export function recordClaim(node: AnyNode): void {
9
+ if (!claimOrder.has(node)) claimOrder.set(node, ++claims);
10
+ }
11
+
12
+ /** Registered nodes first claimed after `mark`, in `nodes` order (pass `registry.nodes`). */
13
+ export const claimedSince = (nodes: readonly AnyNode[], mark: number): AnyNode[] =>
14
+ nodes.filter((node) => claimOrder.get(node)! > mark);
@@ -9,6 +9,7 @@ import { elementKey } from './element-key.js';
9
9
  import { initializeNode, withInitialization } from './initialization.js';
10
10
  import type { DrawingContext } from './context.js';
11
11
  import type { ElementClass } from './types.js';
12
+ import { recordClaim } from './claims.js';
12
13
 
13
14
  export class Registry {
14
15
  private static readonly identity = new WeakMap<SVGElement, AnyNode>();
@@ -83,6 +84,7 @@ export class Registry {
83
84
  if (!this.context.dom.owns(node.el)) throw new Error('Element belongs to another document');
84
85
  Registry.identity.set(node.el, node);
85
86
  this.wrappers.set(node.el, node);
87
+ recordClaim(node);
86
88
  }
87
89
 
88
90
  forget(node: AnyNode): void {