Skip to content

Commit

Permalink
Fix potentially wrong order of RemoteMutationRecords
Browse files Browse the repository at this point in the history
  • Loading branch information
mfal committed Dec 20, 2024
1 parent 0e55486 commit 7cc40f2
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 2 deletions.
30 changes: 30 additions & 0 deletions packages/core/source/elements/RemoteMutationObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
disconnectRemoteNode,
serializeRemoteNode,
REMOTE_IDS,
getStructuralMutationIndex,
} from './internals.ts';
import {
ROOT_ID,
Expand Down Expand Up @@ -36,10 +37,14 @@ export class RemoteMutationObserver extends MutationObserver {
super((records) => {
const addedNodes: Node[] = [];
const remoteRecords: RemoteMutationRecord[] = [];
const netStructuralMutations = new Map<string, number | undefined>();

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

const targetsNetStructuralMutations =
netStructuralMutations.get(targetId) ?? 0;

if (record.type === 'childList') {
const position = record.previousSibling
? indexOf(record.previousSibling, record.target.childNodes) + 1
Expand All @@ -48,6 +53,10 @@ export class RemoteMutationObserver extends MutationObserver {
record.removedNodes.forEach((node) => {
disconnectRemoteNode(node);

netStructuralMutations.set(
targetId,
targetsNetStructuralMutations + 1,
);
remoteRecords.push([
MUTATION_TYPE_REMOVE_CHILD,
targetId,
Expand All @@ -72,6 +81,10 @@ export class RemoteMutationObserver extends MutationObserver {
addedNodes.push(node);
connectRemoteNode(node, connection);

netStructuralMutations.set(
targetId,
targetsNetStructuralMutations - 1,
);
remoteRecords.push([
MUTATION_TYPE_INSERT_CHILD,
targetId,
Expand Down Expand Up @@ -100,10 +113,27 @@ export class RemoteMutationObserver extends MutationObserver {
}
}

const hasCompensatedStructuralMutations = Array.from(
netStructuralMutations.values(),
).includes(0);

if (hasCompensatedStructuralMutations) {
this.sortStructuralMutations(remoteRecords);
}

connection.mutate(remoteRecords);
});
}

/**
* See this issue why sorting is required: https://github.com/Shopify/remote-dom/issues/519
*/
private sortStructuralMutations(records: RemoteMutationRecord[]) {
records.sort(
(l, r) => getStructuralMutationIndex(l) - getStructuralMutationIndex(r),
);
}

/**
* Starts watching changes to the element, and communicates changes to the
* host environment. By default, this method will also communicate any initial
Expand Down
16 changes: 15 additions & 1 deletion packages/core/source/elements/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import {
UPDATE_PROPERTY_TYPE_PROPERTY,
UPDATE_PROPERTY_TYPE_ATTRIBUTE,
UPDATE_PROPERTY_TYPE_EVENT_LISTENER,
MUTATION_TYPE_INSERT_CHILD,
MUTATION_TYPE_REMOVE_CHILD,
} from '../constants.ts';
import type {RemoteConnection, RemoteNodeSerialization} from '../types.ts';
import type {
RemoteConnection,
RemoteMutationRecord,
RemoteNodeSerialization,
} from '../types.ts';

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

Expand Down Expand Up @@ -301,3 +307,11 @@ export function callRemoteElementMethod(

return connection.call(id, method, ...args);
}

export function getStructuralMutationIndex(record: RemoteMutationRecord) {
return record[0] === MUTATION_TYPE_INSERT_CHILD
? record[3]
: record[0] === MUTATION_TYPE_REMOVE_CHILD
? record[2]
: -1;
}
14 changes: 14 additions & 0 deletions packages/core/source/elements/tests/MutationObserverMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {vi} from 'vitest';

export class MutationObserverMock {
public readonly emitMutation = vi.fn<[MutationRecord[]], void>();

public constructor(cb: MutationCallback) {
this.emitMutation.mockImplementation((records) => cb(records, {} as never));
}
}

type MutationObserver = typeof globalThis.MutationObserver;

globalThis.MutationObserver =
MutationObserverMock as unknown as MutationObserver;
117 changes: 117 additions & 0 deletions packages/core/source/elements/tests/RemoteMutationObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import '../../polyfill';
import './MutationObserverMock';
import {MutationObserverMock} from './MutationObserverMock';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {RemoteMutationObserver} from '../RemoteMutationObserver';
import {
MUTATION_TYPE_INSERT_CHILD,
MUTATION_TYPE_REMOVE_CHILD,
} from '../../constants';

let observer: RemoteMutationObserver & MutationObserverMock;
let observedRoot: HTMLDivElement;

let node1: Node;
let node2: Node;
let node3: Node;

const mockedConnection = {
mutate: vi.fn(),
call: vi.fn(),
};

beforeEach(() => {
vi.resetAllMocks();

observer = new RemoteMutationObserver(
mockedConnection,
) as RemoteMutationObserver & MutationObserverMock;

observedRoot = div();
node1 = div();
node2 = div();
node3 = div();
});

interface TestMutation {
addedNodes?: Node[];
removedNodes?: Node[];
previousSibling?: Node;
}

function createMutationRecord(mutation: TestMutation): MutationRecord {
const {addedNodes = [], removedNodes = [], previousSibling} = mutation;

return {
type: 'childList',
addedNodes: addedNodes as never,
removedNodes: removedNodes as never,
previousSibling: previousSibling ?? null,
target: observedRoot,
attributeName: null,
attributeNamespace: null,
nextSibling: null,
oldValue: null,
};
}

const div = () => document.createElement('div');

const insert = (node: Node, after?: Node) =>
createMutationRecord({
addedNodes: [node],
previousSibling: after,
});

const remove = (node: Node, after?: Node) =>
createMutationRecord({
removedNodes: [node],
previousSibling: after,
});

const after = (after: Node) => ({
remove: (node: Node) => remove(node, after),
insert: (node: Node) => insert(node, after),
});

const atStart = {
remove: (node: Node) => remove(node),
insert: (node: Node) => insert(node),
};

function givenFinalNodes(...nodes: Node[]) {
type ExpectedRemoteMutation = [type: number, index: number];

observedRoot.append(...nodes);

return {
createdByMutations(...mutations: MutationRecord[]) {
observer.emitMutation(mutations);

return {
expectRemoteMutations(...expected: ExpectedRemoteMutation[]) {
const remoteMutations = expected.map(([type, index]) =>
type === MUTATION_TYPE_INSERT_CHILD
? [type, expect.anything(), expect.anything(), index]
: [type, expect.anything(), index],
);

expect(mockedConnection.mutate).toHaveBeenCalledWith([
...remoteMutations,
]);
},
};
},
};
}

describe('RemoteMutationObserver', () => {
it('orders structural mutations by their index', () => {
givenFinalNodes(node1, node2)
.createdByMutations(after(node2).remove(node3), atStart.insert(node1))
.expectRemoteMutations(
[MUTATION_TYPE_INSERT_CHILD, 0],
[MUTATION_TYPE_REMOVE_CHILD, 2],
);
});
});
2 changes: 1 addition & 1 deletion packages/polyfill/source/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export class Node extends EventTarget {
while (true) {
if (currentNode == null) return false;
if (currentNode === this) return true;
currentNode = currentNode!.parentNode;
currentNode = currentNode.parentNode;
}
}
}

0 comments on commit 7cc40f2

Please sign in to comment.