Guard receivers against late mutations on detached nodes#611
Conversation
5ab70c6 to
e4e4d8c
Compare
| import {defineConfig} from 'vitest/config'; | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [quiltPackage()], |
There was a problem hiding this comment.
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.
simontaisne
left a comment
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
Should we add another test to confirm retain/release are still called when we updateProperty on an attached element?
There was a problem hiding this comment.
Should we add a test for call() on a detached id to confirm it still throws the explicit “no implementation” error?
| expect(() => | ||
| receiver.connection.mutate([ | ||
| [MUTATION_TYPE_INSERT_CHILD, '1', child, 0], | ||
| ]), | ||
| ).not.toThrow(); |
There was a problem hiding this comment.
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);| 1, | ||
| ); | ||
|
|
||
| if (!removed) { |
There was a problem hiding this comment.
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>
e4e4d8c to
f20f6e7
Compare
TL;DR:
TypeError: undefined is not an object (evaluating 'x.properties')/TypeError: Cannot read properties of undefined (reading 'properties')errors that are polluting our logs (Observe link)What this fixes
RemoteReceiverandSignalRemoteReceivernow dropinsertChild,removeChild,updateProperty, andupdateTextmutations whose target node is no longer in the receiver'sattachedmap, instead of throwing aTypeErrorwhile dereferencing the missing node.TypeError: undefined is not an object (evaluating 'x.properties')TypeError: Cannot read properties of undefined (reading 'properties')removeChildfor an ancestor was processed earlier in the same batch, or arrived first from a separate batched payload.Why the existing guard was not sufficient
removeChildin receivers less strict #533 (a port of Prevents missing child from causing removal of last node #522) added anif (!removed) return;guard toremoveChild. That guard catches the case where the child slot at the given index was empty, but it runs after[...parent.children.peek()], which is the line that throws whenparentitself is missing. It also did not touchupdatePropertyorupdateText.TypeErrorshape onelement.eventListenersand was closed under the assumption that MakeremoveChildin receivers less strict #533 covered it. As far as I can tell from the patch, it did not because bothupdatePropertyandupdateTextare still vulnerable. We are seeing this fire several hundred times a week in Shopify checkout, exclusively grouped on the engine-specific error message text.What this PR does
attached.get(id), in both receivers:Tests
Added regression tests in:
packages/core/source/receivers/tests/RemoteReceiver.test.tspackages/signals/source/tests/SignalRemoteReceiver.test.tsEach suite covers:
insertChildafter parent detachremoveChildafter parent detachupdatePropertyafter element detach (all three type branches: PROPERTY, ATTRIBUTE, EVENT_LISTENER, plus the default-arg case that produced the production error)updateTextafter text-node detachremoveChildand a subsequentupdatePropertyon a descendant in a single mutate([...]) payloadupdatePropertymutationsVerified 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) andpnpm type-checkare green.Requested by Eddie Chan eddie.chan@shopify.com