Skip to content

Commit d356702

Browse files
authored
Merge pull request #611 from Shopify/fix-receiver-late-mutation-guards
Guard receivers against late mutations on detached nodes
2 parents 54250b2 + f20f6e7 commit d356702

6 files changed

Lines changed: 651 additions & 8 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@remote-dom/signals': patch
3+
'@remote-dom/core': patch
4+
---
5+
6+
Guard receivers against late mutations on detached nodes
7+
8+
`RemoteReceiver` and `SignalRemoteReceiver` now drop `insertChild`, `removeChild`, `updateProperty`, and `updateText` mutations whose target node is no longer in the receiver's `attached` map, instead of throwing a `TypeError` while dereferencing the missing node.
9+
10+
This race surfaces in production as unhandled promise rejections such as `TypeError: undefined is not an object (evaluating 'x.properties')` (Safari) / `TypeError: Cannot read properties of undefined (reading 'properties')` (V8) when a remote sender dispatches a mutation for a node whose host-side state has just been removed (for example, a `removeChild` for an ancestor was processed earlier in the same batch, or arrived first from a separate batched payload).
11+
12+
PR #533 previously added a similar guard to `removeChild` for the case where the _child slot_ at a given index was empty, but did not handle the case where the _parent itself_ was missing, and did not touch `updateProperty` or `updateText`. This change applies the same defensive pattern uniformly to every connection callback that dereferences `attached.get(id)`.
13+
14+
Late mutations targeting a detached subtree are by definition no-ops — there is nothing left to mutate.

packages/core/source/receivers/RemoteReceiver.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,14 @@ export class RemoteReceiver {
163163
return implementationMethod(...args);
164164
},
165165
insertChild: (id, child, index) => {
166-
const parent = attached.get(id) as Writable<RemoteReceiverParent>;
166+
const parent = attached.get(id) as
167+
| Writable<RemoteReceiverParent>
168+
| undefined;
169+
170+
// The parent may already have been detached if a `removeChild` for an
171+
// ancestor was processed before this `insertChild` arrived. Drop the
172+
// late mutation; the subtree is already gone.
173+
if (!parent) return;
167174

168175
const {children} = parent;
169176

@@ -185,7 +192,12 @@ export class RemoteReceiver {
185192
runSubscribers(parent);
186193
},
187194
removeChild: (id, index) => {
188-
const parent = attached.get(id) as Writable<RemoteReceiverParent>;
195+
const parent = attached.get(id) as
196+
| Writable<RemoteReceiverParent>
197+
| undefined;
198+
199+
// The parent may already have been detached. Drop the late mutation.
200+
if (!parent) return;
189201

190202
const {children} = parent;
191203

@@ -194,6 +206,9 @@ export class RemoteReceiver {
194206
1,
195207
);
196208

209+
// The slot at the given index was already empty (e.g. an earlier
210+
// late `removeChild` for the same index). Drop the late mutation;
211+
// there is nothing to remove.
197212
if (!removed) {
198213
return;
199214
}
@@ -210,7 +225,14 @@ export class RemoteReceiver {
210225
value,
211226
type = UPDATE_PROPERTY_TYPE_PROPERTY,
212227
) => {
213-
const element = attached.get(id) as Writable<RemoteReceiverElement>;
228+
const element = attached.get(id) as
229+
| Writable<RemoteReceiverElement>
230+
| undefined;
231+
232+
// The element may already have been detached if a `removeChild` for an
233+
// ancestor was processed before this `updateProperty` arrived. Drop the
234+
// late mutation; the node is no longer rendered.
235+
if (!element) return;
214236

215237
retain?.(value);
216238

@@ -256,7 +278,12 @@ export class RemoteReceiver {
256278
release?.(oldValue);
257279
},
258280
updateText: (id, newText) => {
259-
const text = attached.get(id) as Writable<RemoteReceiverText>;
281+
const text = attached.get(id) as
282+
| Writable<RemoteReceiverText>
283+
| undefined;
284+
285+
// The text node may already have been detached. Drop the late mutation.
286+
if (!text) return;
260287

261288
text.data = newText;
262289
text.version += 1;
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import {describe, expect, it, vi} from 'vitest';
2+
3+
import {
4+
MUTATION_TYPE_INSERT_CHILD,
5+
MUTATION_TYPE_REMOVE_CHILD,
6+
MUTATION_TYPE_UPDATE_PROPERTY,
7+
MUTATION_TYPE_UPDATE_TEXT,
8+
NODE_TYPE_ELEMENT,
9+
NODE_TYPE_TEXT,
10+
ROOT_ID,
11+
UPDATE_PROPERTY_TYPE_ATTRIBUTE,
12+
UPDATE_PROPERTY_TYPE_EVENT_LISTENER,
13+
UPDATE_PROPERTY_TYPE_PROPERTY,
14+
} from '../../constants.ts';
15+
import type {
16+
RemoteElementSerialization,
17+
RemoteTextSerialization,
18+
} from '../../types.ts';
19+
import {RemoteReceiver} from '../RemoteReceiver.ts';
20+
21+
describe('RemoteReceiver', () => {
22+
describe('late mutations on detached nodes', () => {
23+
/**
24+
* These tests cover a class of race condition where the remote sender
25+
* dispatches a mutation for a node whose receiver-side state has already
26+
* been removed (e.g. an ancestor `removeChild` was processed earlier in
27+
* the same batch, or a separate teardown mutation arrived first).
28+
*
29+
* The receiver should treat each of these as a no-op rather than throwing
30+
* a `TypeError` while dereferencing the missing node — those throws
31+
* surface to consumers as unhandled promise rejections.
32+
*/
33+
34+
it('drops insertChild for a parent that has been detached', () => {
35+
const receiver = new RemoteReceiver();
36+
37+
const element = elementSerialization('host', '1');
38+
const child = textSerialization('child', '2');
39+
40+
// Build: root -> host
41+
receiver.connection.mutate([
42+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, element, 0],
43+
]);
44+
45+
// Detach `host` so any future mutations targeting it become late.
46+
receiver.connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0]]);
47+
48+
const rootChildrenBefore = [...receiver.root.children];
49+
50+
expect(() =>
51+
receiver.connection.mutate([
52+
[MUTATION_TYPE_INSERT_CHILD, '1', child, 0],
53+
]),
54+
).not.toThrow();
55+
56+
// Confirm the receiver is a true no-op: the late child was never
57+
// attached and the existing tree is unchanged.
58+
expect(receiver.root.children).toStrictEqual(rootChildrenBefore);
59+
expect(receiver.root.children).toHaveLength(0);
60+
expect(receiver.get({id: '2'})).toBeUndefined();
61+
});
62+
63+
it('drops removeChild for a parent that has been detached', () => {
64+
const receiver = new RemoteReceiver();
65+
66+
const parent = elementSerialization('host', '1', [
67+
textSerialization('inner', '2'),
68+
]);
69+
70+
receiver.connection.mutate([
71+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, parent, 0],
72+
]);
73+
74+
receiver.connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0]]);
75+
76+
const rootChildrenBefore = [...receiver.root.children];
77+
78+
expect(() =>
79+
receiver.connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, '1', 0]]),
80+
).not.toThrow();
81+
82+
expect(receiver.root.children).toStrictEqual(rootChildrenBefore);
83+
expect(receiver.root.children).toHaveLength(0);
84+
});
85+
86+
it('drops updateProperty for an element that has been detached', () => {
87+
const receiver = new RemoteReceiver();
88+
89+
const element = elementSerialization('host', '1');
90+
91+
receiver.connection.mutate([
92+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, element, 0],
93+
]);
94+
95+
receiver.connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0]]);
96+
97+
// The default `type` is UPDATE_PROPERTY_TYPE_PROPERTY — historically the
98+
// line that produced `TypeError: ... 'x.properties'` in production.
99+
for (const updateType of [
100+
undefined,
101+
UPDATE_PROPERTY_TYPE_PROPERTY,
102+
UPDATE_PROPERTY_TYPE_ATTRIBUTE,
103+
UPDATE_PROPERTY_TYPE_EVENT_LISTENER,
104+
] as const) {
105+
expect(() =>
106+
receiver.connection.mutate([
107+
[MUTATION_TYPE_UPDATE_PROPERTY, '1', 'value', 'late', updateType],
108+
]),
109+
).not.toThrow();
110+
111+
// The detached element stays detached; the late write did not
112+
// re-materialize it.
113+
expect(receiver.get({id: '1'})).toBeUndefined();
114+
}
115+
});
116+
117+
it('does not call retain/release for late updateProperty mutations', () => {
118+
const retain = vi.fn();
119+
const release = vi.fn();
120+
const receiver = new RemoteReceiver({retain, release});
121+
122+
const element = elementSerialization('host', '1');
123+
124+
receiver.connection.mutate([
125+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, element, 0],
126+
]);
127+
receiver.connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0]]);
128+
129+
retain.mockClear();
130+
release.mockClear();
131+
132+
receiver.connection.mutate([
133+
[
134+
MUTATION_TYPE_UPDATE_PROPERTY,
135+
'1',
136+
'value',
137+
{ref: true},
138+
UPDATE_PROPERTY_TYPE_PROPERTY,
139+
],
140+
]);
141+
142+
// We dropped the mutation entirely, so retain/release were not invoked
143+
// for the new value or the (nonexistent) old value.
144+
expect(retain).not.toHaveBeenCalled();
145+
expect(release).not.toHaveBeenCalled();
146+
});
147+
148+
it('still calls retain/release for updateProperty on an attached element', () => {
149+
const retain = vi.fn();
150+
const release = vi.fn();
151+
const receiver = new RemoteReceiver({retain, release});
152+
153+
const element = elementSerialization('host', '1');
154+
155+
receiver.connection.mutate([
156+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, element, 0],
157+
]);
158+
159+
// Seed an existing property value so the next update has a real
160+
// old value to release.
161+
const oldValue = {ref: 'old'};
162+
receiver.connection.mutate([
163+
[
164+
MUTATION_TYPE_UPDATE_PROPERTY,
165+
'1',
166+
'value',
167+
oldValue,
168+
UPDATE_PROPERTY_TYPE_PROPERTY,
169+
],
170+
]);
171+
172+
retain.mockClear();
173+
release.mockClear();
174+
175+
const newValue = {ref: 'new'};
176+
receiver.connection.mutate([
177+
[
178+
MUTATION_TYPE_UPDATE_PROPERTY,
179+
'1',
180+
'value',
181+
newValue,
182+
UPDATE_PROPERTY_TYPE_PROPERTY,
183+
],
184+
]);
185+
186+
expect(retain).toHaveBeenCalledWith(newValue);
187+
expect(release).toHaveBeenCalledWith(oldValue);
188+
});
189+
190+
it('drops updateText for a text node that has been detached', () => {
191+
const receiver = new RemoteReceiver();
192+
193+
const text = textSerialization('hello', '1');
194+
195+
receiver.connection.mutate([
196+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, text, 0],
197+
]);
198+
199+
receiver.connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0]]);
200+
201+
expect(() =>
202+
receiver.connection.mutate([
203+
[MUTATION_TYPE_UPDATE_TEXT, '1', 'late text'],
204+
]),
205+
).not.toThrow();
206+
207+
expect(receiver.get({id: '1'})).toBeUndefined();
208+
});
209+
210+
it('handles a removeChild + updateProperty pair delivered in the same batch', () => {
211+
// This mirrors the production race: a single batched payload contains
212+
// a removal of a node and a subsequent property update on a descendant
213+
// that has just been detached in the same batch.
214+
const receiver = new RemoteReceiver();
215+
216+
const element = elementSerialization('host', '1', [
217+
elementSerialization('inner', '2'),
218+
]);
219+
220+
receiver.connection.mutate([
221+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, element, 0],
222+
]);
223+
224+
expect(() =>
225+
receiver.connection.mutate([
226+
[MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0],
227+
[
228+
MUTATION_TYPE_UPDATE_PROPERTY,
229+
'2',
230+
'value',
231+
'late',
232+
UPDATE_PROPERTY_TYPE_PROPERTY,
233+
],
234+
]),
235+
).not.toThrow();
236+
237+
// Both nodes are detached after the batch; the late write left
238+
// no residue.
239+
expect(receiver.root.children).toHaveLength(0);
240+
expect(receiver.get({id: '1'})).toBeUndefined();
241+
expect(receiver.get({id: '2'})).toBeUndefined();
242+
});
243+
244+
it('still throws an explicit no-implementation error for call() on a detached id', () => {
245+
// The `call` callback is intentionally not guarded by the late-mutation
246+
// pattern: callers should see a clear error rather than a silent no-op
247+
// when invoking a method on a node that no longer has an implementation.
248+
const receiver = new RemoteReceiver();
249+
250+
const element = elementSerialization('host', '1');
251+
252+
receiver.connection.mutate([
253+
[MUTATION_TYPE_INSERT_CHILD, ROOT_ID, element, 0],
254+
]);
255+
receiver.connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0]]);
256+
257+
expect(() => receiver.connection.call('1', 'doSomething')).toThrow(
258+
'Node 1 does not implement the doSomething() method',
259+
);
260+
});
261+
});
262+
});
263+
264+
function elementSerialization(
265+
element: string,
266+
id: string,
267+
children: ReadonlyArray<
268+
RemoteElementSerialization | RemoteTextSerialization
269+
> = [],
270+
): RemoteElementSerialization {
271+
return {
272+
id,
273+
type: NODE_TYPE_ELEMENT,
274+
element,
275+
properties: {},
276+
attributes: {},
277+
eventListeners: {},
278+
children,
279+
};
280+
}
281+
282+
function textSerialization(data: string, id: string): RemoteTextSerialization {
283+
return {
284+
id,
285+
type: NODE_TYPE_TEXT,
286+
data,
287+
};
288+
}

0 commit comments

Comments
 (0)