Skip to content

Commit 19cc218

Browse files
committed
Fix potentially wrong order of RemoteMutationRecords
1 parent 0e55486 commit 19cc218

File tree

6 files changed

+182
-2
lines changed

6 files changed

+182
-2
lines changed

.changeset/kind-sloths-hope.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@remote-dom/core': patch
3+
---
4+
5+
fix: Fix errors when lists of remote elements change

packages/core/source/elements/RemoteMutationObserver.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
disconnectRemoteNode,
55
serializeRemoteNode,
66
REMOTE_IDS,
7+
getStructuralMutationIndex,
78
} from './internals.ts';
89
import {
910
ROOT_ID,
@@ -36,10 +37,14 @@ export class RemoteMutationObserver extends MutationObserver {
3637
super((records) => {
3738
const addedNodes: Node[] = [];
3839
const remoteRecords: RemoteMutationRecord[] = [];
40+
const netStructuralMutations = new Map<string, number>();
3941

4042
for (const record of records) {
4143
const targetId = remoteId(record.target);
4244

45+
const targetsNetStructuralMutations =
46+
netStructuralMutations.get(targetId) ?? 0;
47+
4348
if (record.type === 'childList') {
4449
const position = record.previousSibling
4550
? indexOf(record.previousSibling, record.target.childNodes) + 1
@@ -48,6 +53,10 @@ export class RemoteMutationObserver extends MutationObserver {
4853
record.removedNodes.forEach((node) => {
4954
disconnectRemoteNode(node);
5055

56+
netStructuralMutations.set(
57+
targetId,
58+
targetsNetStructuralMutations + 1,
59+
);
5160
remoteRecords.push([
5261
MUTATION_TYPE_REMOVE_CHILD,
5362
targetId,
@@ -72,6 +81,10 @@ export class RemoteMutationObserver extends MutationObserver {
7281
addedNodes.push(node);
7382
connectRemoteNode(node, connection);
7483

84+
netStructuralMutations.set(
85+
targetId,
86+
targetsNetStructuralMutations - 1,
87+
);
7588
remoteRecords.push([
7689
MUTATION_TYPE_INSERT_CHILD,
7790
targetId,
@@ -100,10 +113,27 @@ export class RemoteMutationObserver extends MutationObserver {
100113
}
101114
}
102115

116+
const hasCompensatedStructuralMutations = Array.from(
117+
netStructuralMutations.values(),
118+
).includes(0);
119+
120+
if (hasCompensatedStructuralMutations) {
121+
this.sortStructuralMutations(remoteRecords);
122+
}
123+
103124
connection.mutate(remoteRecords);
104125
});
105126
}
106127

128+
/**
129+
* See this issue why sorting is required: https://github.com/Shopify/remote-dom/issues/519
130+
*/
131+
private sortStructuralMutations(records: RemoteMutationRecord[]) {
132+
records.sort(
133+
(l, r) => getStructuralMutationIndex(l) - getStructuralMutationIndex(r),
134+
);
135+
}
136+
107137
/**
108138
* Starts watching changes to the element, and communicates changes to the
109139
* host environment. By default, this method will also communicate any initial

packages/core/source/elements/internals.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import {
33
UPDATE_PROPERTY_TYPE_PROPERTY,
44
UPDATE_PROPERTY_TYPE_ATTRIBUTE,
55
UPDATE_PROPERTY_TYPE_EVENT_LISTENER,
6+
MUTATION_TYPE_INSERT_CHILD,
7+
MUTATION_TYPE_REMOVE_CHILD,
68
} from '../constants.ts';
7-
import type {RemoteConnection, RemoteNodeSerialization} from '../types.ts';
9+
import type {
10+
RemoteConnection,
11+
RemoteMutationRecord,
12+
RemoteNodeSerialization,
13+
} from '../types.ts';
814

915
export const REMOTE_CONNECTIONS = new WeakMap<Node, RemoteConnection>();
1016

@@ -301,3 +307,11 @@ export function callRemoteElementMethod(
301307

302308
return connection.call(id, method, ...args);
303309
}
310+
311+
export function getStructuralMutationIndex(record: RemoteMutationRecord) {
312+
return record[0] === MUTATION_TYPE_INSERT_CHILD
313+
? record[3]
314+
: record[0] === MUTATION_TYPE_REMOVE_CHILD
315+
? record[2]
316+
: -1;
317+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {vi} from 'vitest';
2+
3+
export class MutationObserverMock {
4+
public readonly emitMutation = vi.fn<[MutationRecord[]], void>();
5+
6+
public constructor(cb: MutationCallback) {
7+
this.emitMutation.mockImplementation((records) => cb(records, {} as never));
8+
}
9+
}
10+
11+
type MutationObserver = typeof globalThis.MutationObserver;
12+
13+
globalThis.MutationObserver =
14+
MutationObserverMock as unknown as MutationObserver;
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import '../../polyfill';
2+
import './MutationObserverMock';
3+
import {MutationObserverMock} from './MutationObserverMock';
4+
import {beforeEach, describe, expect, it, vi} from 'vitest';
5+
import {RemoteMutationObserver} from '../RemoteMutationObserver';
6+
import {
7+
MUTATION_TYPE_INSERT_CHILD,
8+
MUTATION_TYPE_REMOVE_CHILD,
9+
} from '../../constants';
10+
11+
let observer: RemoteMutationObserver & MutationObserverMock;
12+
let observedRoot: HTMLDivElement;
13+
14+
let node1: Node;
15+
let node2: Node;
16+
let node3: Node;
17+
18+
const mockedConnection = {
19+
mutate: vi.fn(),
20+
call: vi.fn(),
21+
};
22+
23+
beforeEach(() => {
24+
vi.resetAllMocks();
25+
26+
observer = new RemoteMutationObserver(
27+
mockedConnection,
28+
) as RemoteMutationObserver & MutationObserverMock;
29+
30+
observedRoot = div();
31+
node1 = div();
32+
node2 = div();
33+
node3 = div();
34+
});
35+
36+
interface TestMutation {
37+
addedNodes?: Node[];
38+
removedNodes?: Node[];
39+
previousSibling?: Node;
40+
}
41+
42+
function createMutationRecord(mutation: TestMutation): MutationRecord {
43+
const {addedNodes = [], removedNodes = [], previousSibling} = mutation;
44+
45+
return {
46+
type: 'childList',
47+
addedNodes: addedNodes as never,
48+
removedNodes: removedNodes as never,
49+
previousSibling: previousSibling ?? null,
50+
target: observedRoot,
51+
attributeName: null,
52+
attributeNamespace: null,
53+
nextSibling: null,
54+
oldValue: null,
55+
};
56+
}
57+
58+
const div = () => document.createElement('div');
59+
60+
const insert = (node: Node, after?: Node) =>
61+
createMutationRecord({
62+
addedNodes: [node],
63+
previousSibling: after,
64+
});
65+
66+
const remove = (node: Node, after?: Node) =>
67+
createMutationRecord({
68+
removedNodes: [node],
69+
previousSibling: after,
70+
});
71+
72+
const after = (after: Node) => ({
73+
remove: (node: Node) => remove(node, after),
74+
insert: (node: Node) => insert(node, after),
75+
});
76+
77+
const atStart = {
78+
remove: (node: Node) => remove(node),
79+
insert: (node: Node) => insert(node),
80+
};
81+
82+
function givenFinalNodes(...nodes: Node[]) {
83+
type ExpectedRemoteMutation = [type: number, index: number];
84+
85+
observedRoot.append(...nodes);
86+
87+
return {
88+
createdByMutations(...mutations: MutationRecord[]) {
89+
observer.emitMutation(mutations);
90+
91+
return {
92+
expectRemoteMutations(...expected: ExpectedRemoteMutation[]) {
93+
const remoteMutations = expected.map(([type, index]) =>
94+
type === MUTATION_TYPE_INSERT_CHILD
95+
? [type, expect.anything(), expect.anything(), index]
96+
: [type, expect.anything(), index],
97+
);
98+
99+
expect(mockedConnection.mutate).toHaveBeenCalledWith([
100+
...remoteMutations,
101+
]);
102+
},
103+
};
104+
},
105+
};
106+
}
107+
108+
describe('RemoteMutationObserver', () => {
109+
it('orders structural mutations by their index', () => {
110+
givenFinalNodes(node1, node2)
111+
.createdByMutations(after(node2).remove(node3), atStart.insert(node1))
112+
.expectRemoteMutations(
113+
[MUTATION_TYPE_INSERT_CHILD, 0],
114+
[MUTATION_TYPE_REMOVE_CHILD, 2],
115+
);
116+
});
117+
});

packages/polyfill/source/Node.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export class Node extends EventTarget {
160160
while (true) {
161161
if (currentNode == null) return false;
162162
if (currentNode === this) return true;
163-
currentNode = currentNode!.parentNode;
163+
currentNode = currentNode.parentNode;
164164
}
165165
}
166166
}

0 commit comments

Comments
 (0)