|
| 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