Skip to content

Guard receivers against late mutations on detached nodes#611

Merged
eddiechan merged 1 commit into
mainfrom
fix-receiver-late-mutation-guards
May 8, 2026
Merged

Guard receivers against late mutations on detached nodes#611
eddiechan merged 1 commit into
mainfrom
fix-receiver-late-mutation-guards

Conversation

@eddiechan
Copy link
Copy Markdown
Contributor

@eddiechan eddiechan commented Apr 30, 2026

TL;DR:

What this fixes

  • 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.
  • This race surfaces in production as unhandled promise rejections such as:
    • Safari: TypeError: undefined is not an object (evaluating 'x.properties')
    • V8: TypeError: Cannot read properties of undefined (reading 'properties')
  • This happens 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.

Why the existing guard was not sufficient

What this PR does

  • It applies the same defensive pattern uniformly to every connection callback that dereferences attached.get(id), in both receivers:
const node = attached.get(id) as TheRightTypeOrUndefined;
if (!node) return;
  • Late mutations targeting a detached subtree are by definition no-ops — there is nothing left to mutate. The call callback (the only other entry that takes an id) is unchanged because it already handles the missing-implementation case via an explicit thrown error, which is intentional and surfaces a clear name to consumers.

Tests

Added regression tests in:

  • packages/core/source/receivers/tests/RemoteReceiver.test.ts
  • packages/signals/source/tests/SignalRemoteReceiver.test.ts

Each suite covers:

  1. insertChild after parent detach
  2. removeChild after parent detach
  3. updateProperty after element detach (all three type branches: PROPERTY, ATTRIBUTE, EVENT_LISTENER, plus the default-arg case that produced the production error)
  4. updateText after text-node detach
  5. The "same-batch" race: a removeChild and a subsequent updateProperty on a descendant in a single mutate([...]) payload
  6. retain / release are not invoked for dropped updateProperty mutations

Verified by reverting the source guards and re-running: every new test fails with exactly TypeError: Cannot read properties of undefined (reading 'properties'), matching the production error.

pnpm vitest run (170 tests) and pnpm type-check are green.


Requested by Eddie Chan eddie.chan@shopify.com

@eddiechan eddiechan self-assigned this Apr 30, 2026
@shopify-river shopify-river Bot force-pushed the fix-receiver-late-mutation-guards branch from 5ab70c6 to e4e4d8c Compare April 30, 2026 05:03
@eddiechan eddiechan marked this pull request as ready for review April 30, 2026 16:48
import {defineConfig} from 'vitest/config';

export default defineConfig({
plugins: [quiltPackage()],
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: needed because the new test in packages/signals imports from @remote-dom/core. Without a quiltPackage() plugin in that package's vitest config, vitest tries to resolve @remote-dom/core from the not-yet-built build/ directory and fails. Mirroring packages/core/vite.config.js fixes resolution via quilt:source.

Copy link
Copy Markdown
Member

@simontaisne simontaisne left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one! I like that we're using the same pattern for all callbacks now, and the tests are pretty comprehensive. Good diagnosis too, the #533 guard runs after the line that actually throws when the parent is missing, so this was never covered. A few suggestions inline, but nothing blocking 👍

}
});

it('does not call retain/release for late updateProperty mutations', () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add another test to confirm retain/release are still called when we updateProperty on an attached element?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a test for call() on a detached id to confirm it still throws the explicit “no implementation” error?

Comment on lines +48 to +52
expect(() =>
receiver.connection.mutate([
[MUTATION_TYPE_INSERT_CHILD, '1', child, 0],
]),
).not.toThrow();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests use not.toThrow() which confirms the TypeError is gone, but not that the receiver is truly a no-op. After a late insertChild, we could also assert that receiver.root.children.length stays unchanged.

Something like this

// Before the late mutation
const rootChildrenBefore = [...receiver.root.children];

// After
expect(receiver.root.children).toStrictEqual(rootChildrenBefore);
expect(receiver.root.children).toHaveLength(0);

Copy link
Copy Markdown

@ncardeli ncardeli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job!

1,
);

if (!removed) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four new guards in this file include a short comment explaining the late-mutation rationale, but the pre-existing if (!removed) return; guard from PR #533 sits adjacent to them with no comment. A future reader scanning these stacked early-returns may wonder if one is redundant. A brief comment would parallel the new convention and reduce friction for the next maintainer.

        if (!removed) {
          // The slot at the given index was already empty (e.g. an earlier
          // late removeChild for the same index). Drop the
          // late mutation; nothing to remove.
          return;
        }

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.

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.

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)`.

Late mutations targeting a detached subtree are by definition no-ops:
there is nothing left to mutate.

Fixes the original report in #542 (which #533 was assumed to fix but
did not actually cover).

Requested by Eddie Chan <eddie.chan@shopify.com>
@eddiechan eddiechan force-pushed the fix-receiver-late-mutation-guards branch from e4e4d8c to f20f6e7 Compare May 8, 2026 03:47
@eddiechan eddiechan merged commit d356702 into main May 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants