From 1072502fe93cbf53f43a1f2f1717f69db00a804d Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 3 Apr 2025 13:10:39 +0200 Subject: [PATCH 01/10] switch from index- to ID-based mutation records --- packages/compat/source/adapter/host.ts | 114 ++++++++++-------- packages/compat/source/tests/adapter.test.ts | 35 ++---- .../source/elements/RemoteMutationObserver.ts | 55 ++++----- .../core/source/elements/RemoteRootElement.ts | 9 +- packages/core/source/polyfill.ts | 16 +-- .../source/receivers/DOMRemoteReceiver.ts | 27 +++-- .../core/source/receivers/RemoteReceiver.ts | 34 +++--- packages/core/source/tests/elements.test.ts | 22 ++-- packages/core/source/types.ts | 22 ++-- packages/polyfill/source/ParentNode.ts | 19 ++- packages/polyfill/source/hooks.ts | 4 +- .../signals/source/SignalRemoteReceiver.ts | 37 +++--- 12 files changed, 198 insertions(+), 196 deletions(-) diff --git a/packages/compat/source/adapter/host.ts b/packages/compat/source/adapter/host.ts index ac91637d..e76cf39e 100644 --- a/packages/compat/source/adapter/host.ts +++ b/packages/compat/source/adapter/host.ts @@ -1,34 +1,34 @@ import { - KIND_TEXT as LEGACY_KIND_TEXT, - KIND_COMPONENT as LEGACY_KIND_COMPONENT, - KIND_FRAGMENT as LEGACY_KIND_FRAGMENT, - ACTION_MOUNT as LEGACY_ACTION_MOUNT, ACTION_INSERT_CHILD as LEGACY_ACTION_INSERT_CHILD, + ACTION_MOUNT as LEGACY_ACTION_MOUNT, ACTION_REMOVE_CHILD as LEGACY_ACTION_REMOVE_CHILD, ACTION_UPDATE_PROPS as LEGACY_ACTION_UPDATE_PROPS, ACTION_UPDATE_TEXT as LEGACY_ACTION_UPDATE_TEXT, - type RemoteChannel as LegacyRemoteChannel, + KIND_COMPONENT as LEGACY_KIND_COMPONENT, + KIND_FRAGMENT as LEGACY_KIND_FRAGMENT, + KIND_TEXT as LEGACY_KIND_TEXT, type ActionArgumentMap as LegacyActionArgumentMap, + type RemoteChannel as LegacyRemoteChannel, type RemoteComponentSerialization as LegacyRemoteComponentSerialization, - type RemoteTextSerialization as LegacyRemoteTextSerialization, type RemoteFragmentSerialization as LegacyRemoteFragmentSerialization, + type RemoteTextSerialization as LegacyRemoteTextSerialization, } from '@remote-ui/core'; import { - ROOT_ID, - NODE_TYPE_TEXT, - NODE_TYPE_COMMENT, - NODE_TYPE_ELEMENT, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, - type RemoteMutationRecord, - type RemoteTextSerialization, - type RemoteElementSerialization, + NODE_TYPE_COMMENT, + NODE_TYPE_ELEMENT, + NODE_TYPE_TEXT, + ROOT_ID, + type RemoteCommentSerialization, type RemoteConnection, + type RemoteElementSerialization, + type RemoteMutationRecord, type RemoteNodeSerialization, - type RemoteCommentSerialization, + type RemoteTextSerialization, } from '@remote-dom/core'; export interface LegacyRemoteChannelElementMap { @@ -70,6 +70,7 @@ export function adaptToLegacyRemoteChannel( connection: RemoteConnection, options?: LegacyRemoteChannelOptions, ): LegacyRemoteChannel { + // child node list of a given parent const tree = new Map(); function mutate(records: RemoteMutationRecord[]) { @@ -78,8 +79,18 @@ export function adaptToLegacyRemoteChannel( switch (mutationType) { case MUTATION_TYPE_INSERT_CHILD: { + const parentId = record[1]; const node = record[2]; - const index = record[3]; + const nextSiblingId = record[3]; + if (!tree.has(parentId)) { + tree.set(parentId, []); + } + + const siblings = tree.get(parentId)!; + const index = + nextSiblingId === undefined + ? siblings.length + : siblings.findIndex((child) => child.id === nextSiblingId); persistNode(parentId, node, index); break; } @@ -102,8 +113,8 @@ export function adaptToLegacyRemoteChannel( if (!tree.has(parentId)) { tree.set(parentId, []); } - const parentNode = tree.get(parentId)!; - parentNode.splice(index, 0, { + const siblings = tree.get(parentId)!; + siblings.splice(index, 0, { id: node.id, slot: 'attributes' in node ? node.attributes?.slot : undefined, }); @@ -115,12 +126,15 @@ export function adaptToLegacyRemoteChannel( } } - function removeNode(parentId: string, index: number) { - const parentNode = tree.get(parentId); - if (!parentNode?.[index]) return; + function removeNode(parentId: string, id: string) { + const siblings = tree.get(parentId); + if (!siblings) { + return; + } + const index = siblings?.findIndex((child) => child.id === id); + if (index === -1) return; - const id = parentNode[index].id; - parentNode.splice(index, 1); + siblings.splice(index, 1); cleanupNode(id); } @@ -146,12 +160,11 @@ export function adaptToLegacyRemoteChannel( payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_MOUNT]; const records = nodes.map( - (node, index) => + (node) => [ MUTATION_TYPE_INSERT_CHILD, ROOT_ID, adaptLegacyNodeSerialization(node, options), - index, ] satisfies RemoteMutationRecord, ); @@ -166,27 +179,30 @@ export function adaptToLegacyRemoteChannel( const records = []; - const parentNode = tree.get(parentId); + const siblings = tree.get(parentId) ?? []; + const relevantSiblings = [...siblings]; - if (parentNode) { - const existingChildIndex = parentNode.findIndex( - ({id}) => id === child.id, - ); + const existingChildIndex = relevantSiblings.findIndex( + ({id}) => id === child.id, + ); - if (existingChildIndex >= 0) { - records.push([ - MUTATION_TYPE_REMOVE_CHILD, - parentId, - existingChildIndex, - ] satisfies RemoteMutationRecord); - } + if (existingChildIndex >= 0) { + records.push([ + MUTATION_TYPE_REMOVE_CHILD, + parentId, + child.id, + ] satisfies RemoteMutationRecord); + + relevantSiblings.splice(existingChildIndex, 1); } + const nextSibling = relevantSiblings[index]; + records.push([ MUTATION_TYPE_INSERT_CHILD, parentId, adaptLegacyNodeSerialization(child, options), - index, + nextSibling?.id, ] satisfies RemoteMutationRecord); mutate(records); @@ -195,12 +211,13 @@ export function adaptToLegacyRemoteChannel( } case LEGACY_ACTION_REMOVE_CHILD: { - const [parentID, removeIndex] = + const [parentId = ROOT_ID, index] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_REMOVE_CHILD]; - - mutate([ - [MUTATION_TYPE_REMOVE_CHILD, parentID ?? ROOT_ID, removeIndex], - ]); + const id = tree.get(parentId)?.[index]?.id; + if (id === undefined) { + return; + } + mutate([[MUTATION_TYPE_REMOVE_CHILD, parentId, id]]); break; } @@ -217,19 +234,19 @@ export function adaptToLegacyRemoteChannel( case LEGACY_ACTION_UPDATE_PROPS: { const [id, props] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_UPDATE_PROPS]; - const parentNode = tree.get(id); + const siblings = tree.get(id); const records = []; for (const [key, value] of Object.entries(props)) { - const index = parentNode?.findIndex(({slot}) => slot === key) ?? -1; + const slotNodeId = siblings?.find(({slot}) => slot === key)?.id; if (isFragment(value)) { - if (index >= 0) { + if (slotNodeId !== undefined) { records.push([ MUTATION_TYPE_REMOVE_CHILD, id, - index, + slotNodeId, ] satisfies RemoteMutationRecord); } @@ -237,14 +254,13 @@ export function adaptToLegacyRemoteChannel( MUTATION_TYPE_INSERT_CHILD, id, adaptLegacyPropFragmentSerialization(key, value, options), - tree.get(id)?.length ?? 0, ] satisfies RemoteMutationRecord); } else { - if (index >= 0) { + if (slotNodeId !== undefined) { records.push([ MUTATION_TYPE_REMOVE_CHILD, id, - index, + slotNodeId, ] satisfies RemoteMutationRecord); } else { records.push([ diff --git a/packages/compat/source/tests/adapter.test.ts b/packages/compat/source/tests/adapter.test.ts index f1e86f3d..9c4c1127 100644 --- a/packages/compat/source/tests/adapter.test.ts +++ b/packages/compat/source/tests/adapter.test.ts @@ -20,9 +20,9 @@ import { MUTATION_TYPE_REMOVE_CHILD, MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, + NODE_TYPE_COMMENT, NODE_TYPE_ELEMENT, NODE_TYPE_TEXT, - NODE_TYPE_COMMENT, ROOT_ID, } from '@remote-dom/core'; @@ -52,7 +52,6 @@ describe('adaptToLegacyRemoteChannel()', () => { type: NODE_TYPE_TEXT, data: 'I am a text', }, - 0, ], ]); @@ -98,7 +97,6 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, - 0, ], ]); @@ -151,7 +149,6 @@ describe('adaptToLegacyRemoteChannel()', () => { type: NODE_TYPE_COMMENT, data: 'added by remote-ui legacy adaptor to replace a fragment rendered as a child', }, - 0, ], ]); @@ -226,7 +223,6 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, - 0, ], ]); @@ -314,7 +310,6 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, - 0, ], ]); @@ -449,7 +444,6 @@ describe('adaptToLegacyRemoteChannel()', () => { ], properties: {}, }, - 0, ], ]); @@ -559,7 +553,7 @@ describe('adaptToLegacyRemoteChannel()', () => { properties: {}, children: [{id: '2', type: NODE_TYPE_TEXT, data: 'I am a button'}], }, - 1, + undefined, ], ]); @@ -639,7 +633,7 @@ describe('adaptToLegacyRemoteChannel()', () => { ); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, '1', 0], + [MUTATION_TYPE_REMOVE_CHILD, '1', '2'], [ MUTATION_TYPE_INSERT_CHILD, '1', @@ -650,7 +644,7 @@ describe('adaptToLegacyRemoteChannel()', () => { properties: {}, children: [], }, - 1, + undefined, ], ]); @@ -755,7 +749,7 @@ describe('adaptToLegacyRemoteChannel()', () => { ], properties: {}, }, - 1, + undefined, ], ]); @@ -834,7 +828,7 @@ describe('adaptToLegacyRemoteChannel()', () => { channel(ACTION_REMOVE_CHILD, '2', 0); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, '2', 0], + [MUTATION_TYPE_REMOVE_CHILD, '2', '1'], ]); expect(receiver.root.children).toStrictEqual([ @@ -881,7 +875,7 @@ describe('adaptToLegacyRemoteChannel()', () => { channel(ACTION_REMOVE_CHILD, '3', 1); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, '3', 1], + [MUTATION_TYPE_REMOVE_CHILD, '3', '2'], ]); expect(receiver.root.children).toStrictEqual([ @@ -1034,7 +1028,6 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, - 0, ], ]); @@ -1102,7 +1095,6 @@ describe('adaptToLegacyRemoteChannel()', () => { properties: {}, children: [], }, - 0, ], ]); }); @@ -1167,7 +1159,6 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, - 0, ], ]); @@ -1250,7 +1241,7 @@ describe('adaptToLegacyRemoteChannel()', () => { channel(ACTION_REMOVE_CHILD, '2', 0); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, '2', 0], + [MUTATION_TYPE_REMOVE_CHILD, '2', '1'], ]); expect(receiver.root.children).toStrictEqual([ @@ -1277,7 +1268,7 @@ describe('adaptToLegacyRemoteChannel()', () => { channel(ACTION_REMOVE_CHILD, '2', 0); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, '2', 0], + [MUTATION_TYPE_REMOVE_CHILD, '2', '0'], ]); expect(receiver.root.children).toStrictEqual([ @@ -1342,7 +1333,7 @@ describe('adaptToLegacyRemoteChannel()', () => { type: NODE_TYPE_TEXT, data: 'I am the third child', }, - 1, + '0', ], ]); @@ -1382,7 +1373,7 @@ describe('adaptToLegacyRemoteChannel()', () => { channel(ACTION_REMOVE_CHILD, '2', 0); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, '2', 0], + [MUTATION_TYPE_REMOVE_CHILD, '2', '1'], ]); expect(receiver.root.children).toStrictEqual([ @@ -1415,7 +1406,7 @@ describe('adaptToLegacyRemoteChannel()', () => { channel(ACTION_REMOVE_CHILD, '2', 0); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, '2', 0], + [MUTATION_TYPE_REMOVE_CHILD, '2', '3'], ]); expect(receiver.root.children).toStrictEqual([ @@ -1467,7 +1458,7 @@ describe('adaptToLegacyRemoteChannel()', () => { channel(ACTION_REMOVE_CHILD, ROOT_ID, 0); expect(receiver.connection.mutate).toHaveBeenCalledWith([ - [MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, 0], + [MUTATION_TYPE_REMOVE_CHILD, ROOT_ID, '1'], ]); expect(receiver.root.children).toStrictEqual([]); diff --git a/packages/core/source/elements/RemoteMutationObserver.ts b/packages/core/source/elements/RemoteMutationObserver.ts index 518d949f..4c0bdb67 100644 --- a/packages/core/source/elements/RemoteMutationObserver.ts +++ b/packages/core/source/elements/RemoteMutationObserver.ts @@ -1,18 +1,18 @@ import { - remoteId, - connectRemoteNode, - disconnectRemoteNode, - serializeRemoteNode, - REMOTE_IDS, -} from './internals.ts'; -import { - ROOT_ID, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, - MUTATION_TYPE_UPDATE_TEXT, MUTATION_TYPE_UPDATE_PROPERTY, + MUTATION_TYPE_UPDATE_TEXT, + ROOT_ID, } from '../constants.ts'; import type {RemoteConnection, RemoteMutationRecord} from '../types.ts'; +import { + connectRemoteNode, + disconnectRemoteNode, + REMOTE_IDS, + remoteId, + serializeRemoteNode, +} from './internals.ts'; /** * Builds on the browser’s [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) @@ -41,31 +41,29 @@ export class RemoteMutationObserver extends MutationObserver { const targetId = remoteId(record.target); if (record.type === 'childList') { - const position = record.previousSibling - ? indexOf(record.previousSibling, record.target.childNodes) + 1 - : 0; - record.removedNodes.forEach((node) => { disconnectRemoteNode(node); remoteRecords.push([ MUTATION_TYPE_REMOVE_CHILD, targetId, - position, + remoteId(node), ]); + + addedNodes.splice(addedNodes.indexOf(node), 1); }); - // A mutation observer will queue some changes, so we might get one record - // for attaching a parent element, and additional records for attaching descendants. - // We serialize the entire tree when a new node was added, so we don’t want to - // send additional “insert child” records when we see those descendants — they - // will already be included the insertion of the parent. - record.addedNodes.forEach((node, index) => { + record.addedNodes.forEach((node) => { if ( - addedNodes.some((addedNode) => { - return addedNode === node || addedNode.contains(node); - }) + addedNodes.some( + (added) => addedNodes.includes(node) || added.contains(node), + ) ) { + // A mutation observer will queue some changes, so we might get one record + // for attaching a parent element, and additional records for attaching descendants. + // We serialize the entire tree when a new node was added, so we don’t want to + // send additional “insert child” records when we see those descendants — they + // will already be included the insertion of the parent. return; } @@ -76,7 +74,7 @@ export class RemoteMutationObserver extends MutationObserver { MUTATION_TYPE_INSERT_CHILD, targetId, serializeRemoteNode(node), - position + index, + record.nextSibling ? remoteId(record.nextSibling) : undefined, ]); }); } else if (record.type === 'characterData') { @@ -134,7 +132,6 @@ export class RemoteMutationObserver extends MutationObserver { MUTATION_TYPE_INSERT_CHILD, ROOT_ID, serializeRemoteNode(node), - i, ]); } @@ -150,11 +147,3 @@ export class RemoteMutationObserver extends MutationObserver { }); } } - -function indexOf(node: Node, list: NodeList) { - for (let i = 0; i < list.length; i++) { - if (list[i] === node) return i; - } - - return -1; -} diff --git a/packages/core/source/elements/RemoteRootElement.ts b/packages/core/source/elements/RemoteRootElement.ts index 393f83ca..a46d253d 100644 --- a/packages/core/source/elements/RemoteRootElement.ts +++ b/packages/core/source/elements/RemoteRootElement.ts @@ -1,13 +1,13 @@ -import {ROOT_ID, MUTATION_TYPE_INSERT_CHILD} from '../constants.ts'; +import {MUTATION_TYPE_INSERT_CHILD, ROOT_ID} from '../constants.ts'; import type {RemoteConnection, RemoteMutationRecord} from '../types.ts'; import { - remoteConnection, + callRemoteElementMethod, connectRemoteNode, + REMOTE_IDS, + remoteConnection, serializeRemoteNode, updateRemoteElementProperty, - callRemoteElementMethod, - REMOTE_IDS, } from './internals.ts'; /** @@ -54,7 +54,6 @@ export class RemoteRootElement extends HTMLElement { MUTATION_TYPE_INSERT_CHILD, ROOT_ID, serializeRemoteNode(node), - i, ]); } diff --git a/packages/core/source/polyfill.ts b/packages/core/source/polyfill.ts index 93b3bfc0..fa105702 100644 --- a/packages/core/source/polyfill.ts +++ b/packages/core/source/polyfill.ts @@ -1,4 +1,4 @@ -import {Window, HOOKS, type Hooks} from '@remote-dom/polyfill'; +import {HOOKS, Window, type Hooks} from '@remote-dom/polyfill'; import { MUTATION_TYPE_INSERT_CHILD, @@ -6,10 +6,10 @@ import { MUTATION_TYPE_UPDATE_TEXT, } from './constants.ts'; import { - remoteId, - remoteConnection, connectRemoteNode, disconnectRemoteNode, + remoteConnection, + remoteId, serializeRemoteNode, updateRemoteElementAttribute, } from './elements/internals.ts'; @@ -19,7 +19,7 @@ const hooks = window[HOOKS]; Window.setGlobal(window); -hooks.insertChild = (parent, node, index) => { +hooks.insertChild = (parent, node) => { const connection = remoteConnection(parent); if (connection == null) return; @@ -30,18 +30,20 @@ hooks.insertChild = (parent, node, index) => { MUTATION_TYPE_INSERT_CHILD, remoteId(parent), serializeRemoteNode(node), - index, + node.nextSibling ? remoteId(node.nextSibling) : undefined, ], ]); }; -hooks.removeChild = (parent, node, index) => { +hooks.removeChild = (parent, node) => { const connection = remoteConnection(parent); if (connection == null) return; disconnectRemoteNode(node); - connection.mutate([[MUTATION_TYPE_REMOVE_CHILD, remoteId(parent), index]]); + connection.mutate([ + [MUTATION_TYPE_REMOVE_CHILD, remoteId(parent), remoteId(node)], + ]); }; hooks.setText = (text, data) => { diff --git a/packages/core/source/receivers/DOMRemoteReceiver.ts b/packages/core/source/receivers/DOMRemoteReceiver.ts index 0962b9c6..20e1dce5 100644 --- a/packages/core/source/receivers/DOMRemoteReceiver.ts +++ b/packages/core/source/receivers/DOMRemoteReceiver.ts @@ -1,12 +1,12 @@ import {createRemoteConnection, type RemoteConnection} from '../connection.ts'; import { - NODE_TYPE_TEXT, NODE_TYPE_COMMENT, NODE_TYPE_ELEMENT, + NODE_TYPE_TEXT, ROOT_ID, - UPDATE_PROPERTY_TYPE_PROPERTY, UPDATE_PROPERTY_TYPE_ATTRIBUTE, UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_PROPERTY, } from '../constants.ts'; import type {RemoteNodeSerialization} from '../types.ts'; import type {RemoteReceiverOptions} from './shared.ts'; @@ -105,27 +105,32 @@ export class DOMRemoteReceiver { ? call(element as any, method, ...args) : (element as any)[method](...args); }, - insertChild: (id, child, index) => { - const parent = id === ROOT_ID ? this.root : attached.get(id)!; + insertChild: (parentId, child, nextSiblingId) => { + const parent = + parentId === ROOT_ID ? this.root : attached.get(parentId)!; + const normalizedChild = attach(child); - const existingTimeout = destroyTimeouts.get(id); + const existingTimeout = destroyTimeouts.get(parentId); if (existingTimeout) clearTimeout(existingTimeout); - parent.insertBefore(attach(child), parent.childNodes[index] || null); + if (nextSiblingId === undefined) { + parent.appendChild(normalizedChild); + } else { + parent.insertBefore(normalizedChild, attached.get(nextSiblingId)!); + } }, - removeChild: (id, index) => { - const parent = id === ROOT_ID ? this.root : attached.get(id)!; - const child = parent.childNodes[index]!; + removeChild: (parentId, id) => { + const child = attached.get(id) as ChildNode; child.remove(); if (cache?.maxAge) { - const existingTimeout = destroyTimeouts.get(id); + const existingTimeout = destroyTimeouts.get(parentId); if (existingTimeout) clearTimeout(existingTimeout); const timeout = setTimeout(() => { detach(child); }, cache.maxAge); - destroyTimeouts.set(id, timeout as any); + destroyTimeouts.set(parentId, timeout as any); } else { detach(child); } diff --git a/packages/core/source/receivers/RemoteReceiver.ts b/packages/core/source/receivers/RemoteReceiver.ts index 5d8ad358..8da39d86 100644 --- a/packages/core/source/receivers/RemoteReceiver.ts +++ b/packages/core/source/receivers/RemoteReceiver.ts @@ -10,10 +10,10 @@ import { UPDATE_PROPERTY_TYPE_PROPERTY, } from '../constants.ts'; import type { - RemoteTextSerialization, RemoteCommentSerialization, RemoteElementSerialization, RemoteNodeSerialization, + RemoteTextSerialization, } from '../types.ts'; import type {RemoteReceiverOptions} from './shared.ts'; @@ -162,21 +162,16 @@ export class RemoteReceiver { return implementationMethod(...args); }, - insertChild: (id, child, index) => { - const parent = attached.get(id) as Writable; - - const {children} = parent; - + insertChild: (parentId, child, nextSiblingId) => { + const parent = attached.get(parentId) as Writable; + const children = parent.children as Writable; const normalizedChild = attach(child, parent); - if (index === children.length) { - (children as Writable).push(normalizedChild); + if (nextSiblingId === undefined) { + children.push(normalizedChild); } else { - (children as Writable).splice( - index, - 0, - normalizedChild, - ); + const sibling = attached.get(nextSiblingId) as RemoteReceiverNode; + children.splice(children.indexOf(sibling), 0, normalizedChild); } parent.version += 1; @@ -184,15 +179,14 @@ export class RemoteReceiver { runSubscribers(parent); }, - removeChild: (id, index) => { - const parent = attached.get(id) as Writable; + removeChild: (parentId, id) => { + const parent = attached.get(parentId) as Writable; + const children = parent.children as Writable; - const {children} = parent; + const node = attached.get(id) as Writable; + const index = parent.children.indexOf(node); - const [removed] = (children as Writable).splice( - index, - 1, - ); + const [removed] = children.splice(index, 1); if (!removed) { return; diff --git a/packages/core/source/tests/elements.test.ts b/packages/core/source/tests/elements.test.ts index 1c85e3d7..b34c7d9f 100644 --- a/packages/core/source/tests/elements.test.ts +++ b/packages/core/source/tests/elements.test.ts @@ -1,9 +1,17 @@ +import {describe, expect, it, vi, type MockedObject} from 'vitest'; import '../polyfill.ts'; -import {describe, it, expect, vi, type MockedObject} from 'vitest'; +import {NAME, OWNER_DOCUMENT} from '../../../polyfill/source/constants.ts'; +import { + MUTATION_TYPE_INSERT_CHILD, + MUTATION_TYPE_UPDATE_PROPERTY, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_PROPERTY, +} from '../constants.ts'; import { - RemoteElement, createRemoteElement, + RemoteElement, RemoteEvent, // remoteProperties, // remoteProperty, @@ -15,14 +23,6 @@ import { RemoteReceiver, type RemoteReceiverElement, } from '../receivers/RemoteReceiver.ts'; -import { - MUTATION_TYPE_UPDATE_PROPERTY, - UPDATE_PROPERTY_TYPE_PROPERTY, - UPDATE_PROPERTY_TYPE_ATTRIBUTE, - MUTATION_TYPE_INSERT_CHILD, - UPDATE_PROPERTY_TYPE_EVENT_LISTENER, -} from '../constants.ts'; -import {NAME, OWNER_DOCUMENT} from '../../../polyfill/source/constants.ts'; describe('RemoteElement', () => { describe('properties', () => { @@ -783,7 +783,6 @@ describe('RemoteElement', () => { expect.objectContaining({ attributes: {name}, }), - 0, ], ]); }); @@ -809,7 +808,6 @@ describe('RemoteElement', () => { expect.objectContaining({ attributes: {slot}, }), - 0, ], ]); }); diff --git a/packages/core/source/types.ts b/packages/core/source/types.ts index ab690e4c..556587a9 100644 --- a/packages/core/source/types.ts +++ b/packages/core/source/types.ts @@ -1,14 +1,14 @@ import type { - NODE_TYPE_ELEMENT, - NODE_TYPE_TEXT, - NODE_TYPE_COMMENT, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, - MUTATION_TYPE_UPDATE_TEXT, MUTATION_TYPE_UPDATE_PROPERTY, - UPDATE_PROPERTY_TYPE_PROPERTY, - UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + MUTATION_TYPE_UPDATE_TEXT, + NODE_TYPE_COMMENT, + NODE_TYPE_ELEMENT, + NODE_TYPE_TEXT, UPDATE_PROPERTY_TYPE_ATTRIBUTE, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_PROPERTY, } from './constants.ts'; /** @@ -20,7 +20,7 @@ export type RemoteMutationRecordInsertChild = [ /** * The ID of the parent node. */ - id: string, + parentId: string, /** * A description of the child node being inserted. @@ -33,7 +33,7 @@ export type RemoteMutationRecordInsertChild = [ /** * The index in the parents’ children to insert the new child. */ - index: number, + nextSiblingId?: string, ]; /** @@ -45,12 +45,12 @@ export type RemoteMutationRecordRemoveChild = [ /** * The ID of the parent node. */ - id: string, + parentId: string, /** - * The index of the child to remove. + * The ID of the child to remove. */ - index: number, + id: string, ]; /** diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index b40d0c34..30448535 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -1,17 +1,17 @@ +import {ChildNode, toNode} from './ChildNode.ts'; import { CHILD, - NEXT, - PREV, - PARENT, - OWNER_DOCUMENT, - NodeType, HOOKS, IS_CONNECTED, + NEXT, + NodeType, + OWNER_DOCUMENT, + PARENT, + PREV, } from './constants.ts'; import type {Node} from './Node.ts'; -import {ChildNode, toNode} from './ChildNode.ts'; import {NodeList} from './NodeList.ts'; -import {querySelectorAll, querySelector} from './selectors.ts'; +import {querySelector, querySelectorAll} from './selectors.ts'; import {selfAndDescendants} from './shared.ts'; export class ParentNode extends ChildNode { @@ -80,7 +80,7 @@ export class ParentNode extends ChildNode { } if (this.nodeType === NodeType.ELEMENT_NODE) { - this[HOOKS].removeChild?.(this as any, child as any, childNodesIndex); + this[HOOKS].removeChild?.(this as any, child as any); } } @@ -163,7 +163,6 @@ export class ParentNode extends ChildNode { } } } else { - insertIndex = childNodes.length; childNodes.push(child); if (isElement) this.children.push(child); } @@ -176,7 +175,7 @@ export class ParentNode extends ChildNode { } if (this.nodeType === NodeType.ELEMENT_NODE) { - this[HOOKS].insertChild?.(this as any, child as any, insertIndex); + this[HOOKS].insertChild?.(this as any, child as any); } } } diff --git a/packages/polyfill/source/hooks.ts b/packages/polyfill/source/hooks.ts index 3f3ad888..4f74d655 100644 --- a/packages/polyfill/source/hooks.ts +++ b/packages/polyfill/source/hooks.ts @@ -9,8 +9,8 @@ export interface Hooks { removeAttribute(element: Element, name: string, ns?: string | null): void; createText(text: Text, data: string): void; setText(text: Text, data: string): void; - insertChild(parent: Element, node: Element | Text, index: number): void; - removeChild(parent: Element, node: Element | Text, index: number): void; + insertChild(parent: Element, node: Element | Text): void; + removeChild(parent: Element, node: Element | Text): void; addEventListener( element: EventTarget, type: string, diff --git a/packages/signals/source/SignalRemoteReceiver.ts b/packages/signals/source/SignalRemoteReceiver.ts index 6af8aeaf..e5f612e7 100644 --- a/packages/signals/source/SignalRemoteReceiver.ts +++ b/packages/signals/source/SignalRemoteReceiver.ts @@ -1,25 +1,25 @@ import { - signal, batch, + signal, type ReadonlySignal, type Signal, } from '@preact/signals-core'; import { - ROOT_ID, - NODE_TYPE_ROOT, - NODE_TYPE_ELEMENT, NODE_TYPE_COMMENT, + NODE_TYPE_ELEMENT, + NODE_TYPE_ROOT, NODE_TYPE_TEXT, - UPDATE_PROPERTY_TYPE_PROPERTY, + ROOT_ID, UPDATE_PROPERTY_TYPE_ATTRIBUTE, UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_PROPERTY, createRemoteConnection, + type RemoteCommentSerialization, type RemoteConnection, + type RemoteElementSerialization, type RemoteNodeSerialization, type RemoteTextSerialization, - type RemoteCommentSerialization, - type RemoteElementSerialization, } from '@remote-dom/core'; import type {RemoteReceiverOptions} from '@remote-dom/core/receivers'; @@ -158,25 +158,34 @@ export class SignalRemoteReceiver { return implementationMethod(...args); }, - insertChild: (id, child, index) => { - const parent = attached.get(id) as SignalRemoteReceiverParent; + insertChild: (parentId, child, nextSiblingId) => { + const parent = attached.get(parentId) as SignalRemoteReceiverParent; const newChildren = [...parent.children.peek()]; const normalizedChild = attach(child, parent); - if (index === newChildren.length) { + if (nextSiblingId === undefined) { newChildren.push(normalizedChild); } else { - newChildren.splice(index, 0, normalizedChild); + const nextSibling = attached.get( + nextSiblingId, + ) as SignalRemoteReceiverNode; + newChildren.splice( + newChildren.indexOf(nextSibling), + 0, + normalizedChild, + ); } (parent.children as any).value = newChildren; }, - removeChild: (id, index) => { - const parent = attached.get(id) as SignalRemoteReceiverParent; - + removeChild: (parentId, id) => { + const parent = attached.get(parentId) as SignalRemoteReceiverParent; const newChildren = [...parent.children.peek()]; + const node = attached.get(id) as SignalRemoteReceiverNode; + const index = newChildren.indexOf(node); + const [removed] = newChildren.splice(index, 1); if (!removed) { From 8ca0bf5736ed126915fe40ff0295a57e0c46a04f Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 3 Apr 2025 13:11:08 +0200 Subject: [PATCH 02/10] add e2e tests for structural mutations --- e2e/mutations.e2e.ts | 17 +++++ examples/kitchen-sink/app/host/components.tsx | 16 ++-- examples/kitchen-sink/app/host/state.ts | 5 +- examples/kitchen-sink/app/remote/elements.ts | 7 +- .../app/remote/examples/react-mutations.tsx | 75 +++++++++++++++++++ .../app/remote/examples/utils/react-hooks.ts | 33 ++++++++ examples/kitchen-sink/app/remote/render.ts | 5 ++ examples/kitchen-sink/app/types.ts | 7 +- 8 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 e2e/mutations.e2e.ts create mode 100644 examples/kitchen-sink/app/remote/examples/react-mutations.tsx create mode 100644 examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts diff --git a/e2e/mutations.e2e.ts b/e2e/mutations.e2e.ts new file mode 100644 index 00000000..1350b695 --- /dev/null +++ b/e2e/mutations.e2e.ts @@ -0,0 +1,17 @@ +import {expect, test} from '@playwright/test'; + +const testSet: Array<[string, string, string]> = [ + ['iframe', 'react-mutations-1', 'Data: 1\nData: 2\nData: 3\nData: 4'], + ['iframe', 'react-mutations-2', 'Data: 1\nData: 2'], +]; + +testSet.forEach(([sandbox, example, expectedText]) => { + test(`mutations are applied correctly with ${sandbox} sandbox and ${example} example`, async ({ + page, + }) => { + await page.goto(`/?sandbox=${sandbox}&example=${example}`); + await expect(page.getByTestId('test-done')).toBeAttached(); + const testStack = page.getByTestId('test-stack'); + expect(await testStack.innerText()).toBe(expectedText); + }); +}); diff --git a/examples/kitchen-sink/app/host/components.tsx b/examples/kitchen-sink/app/host/components.tsx index d12829e0..98748a71 100644 --- a/examples/kitchen-sink/app/host/components.tsx +++ b/examples/kitchen-sink/app/host/components.tsx @@ -1,16 +1,16 @@ +import type {Signal} from '@preact/signals'; import {type ComponentChildren} from 'preact'; import {forwardRef} from 'preact/compat'; -import {useRef, useImperativeHandle} from 'preact/hooks'; -import type {Signal} from '@preact/signals'; +import {useImperativeHandle, useRef} from 'preact/hooks'; import type { ButtonProperties, - StackProperties, - TextProperties, ModalMethods, ModalProperties, - RenderSandbox, RenderExample, + RenderSandbox, + StackProperties, + TextProperties, } from '../types.ts'; export function Text({ @@ -51,11 +51,13 @@ export function Button({ } export function Stack({ + testId, spacing, children, }: {children?: ComponentChildren} & StackProperties) { return (
{children} @@ -160,6 +162,8 @@ export function ControlPanel({ + + @@ -194,6 +198,8 @@ const EXAMPLE_FILE_NAMES = new Map([ ['htm', 'htm.ts'], ['preact', 'preact.tsx'], ['react', 'react.tsx'], + ['react-mutations-1', 'react-mutations.tsx'], + ['react-mutations-2', 'react-mutations.tsx'], ['svelte', 'App.svelte'], ['vue', 'App.vue'], ]); diff --git a/examples/kitchen-sink/app/host/state.ts b/examples/kitchen-sink/app/host/state.ts index 2d4bae57..5321f1b2 100644 --- a/examples/kitchen-sink/app/host/state.ts +++ b/examples/kitchen-sink/app/host/state.ts @@ -1,4 +1,4 @@ -import {signal, effect} from '@preact/signals'; +import {effect, signal} from '@preact/signals'; import {SignalRemoteReceiver} from '@remote-dom/preact/host'; import type {RenderExample, RenderSandbox} from '../types.ts'; @@ -12,8 +12,11 @@ const ALLOWED_EXAMPLE_VALUES = new Set([ 'htm', 'preact', 'react', + 'react-mutations', 'svelte', 'vue', + 'react-mutations-1', + 'react-mutations-2', ]); export function createState( diff --git a/examples/kitchen-sink/app/remote/elements.ts b/examples/kitchen-sink/app/remote/elements.ts index 940dd067..a3c1b16a 100644 --- a/examples/kitchen-sink/app/remote/elements.ts +++ b/examples/kitchen-sink/app/remote/elements.ts @@ -1,16 +1,16 @@ import { createRemoteElement, - RemoteRootElement, RemoteFragmentElement, + RemoteRootElement, type RemoteEvent, } from '@remote-dom/core/elements'; import type { - TextProperties, ButtonProperties, - ModalProperties, ModalMethods, + ModalProperties, StackProperties, + TextProperties, } from '../types.ts'; // In this file we will define the custom elements that can be rendered in the @@ -49,6 +49,7 @@ export const Modal = createRemoteElement< export const Stack = createRemoteElement({ properties: { spacing: {type: Boolean}, + testId: {type: String}, }, }); diff --git a/examples/kitchen-sink/app/remote/examples/react-mutations.tsx b/examples/kitchen-sink/app/remote/examples/react-mutations.tsx new file mode 100644 index 00000000..d8c70cd5 --- /dev/null +++ b/examples/kitchen-sink/app/remote/examples/react-mutations.tsx @@ -0,0 +1,75 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource react */ + +import {createRemoteComponent} from '@remote-dom/react'; +import {createRoot} from 'react-dom/client'; + +import type {RenderAPI} from '../../types.ts'; +import {Stack as StackElement, Text as TextElement} from '../elements.ts'; +import {useRenders} from './utils/react-hooks.ts'; + +const Stack = createRemoteComponent('ui-stack', StackElement); +const Text = createRemoteComponent('ui-text', TextElement); + +const data1 = Data: 1; +const data2 = Data: 2; +const data3 = Data: 3; +const data4 = Data: 4; +const done = ; +const loading1 = Loading: 1; +const loading2 = Loading: 2; + +const Example1 = () => { + const renders = useRenders(2); + + return ( + + <> + {renders === 1 && loading1} + {renders === 2 && ( + <> + {data1} + {data2} + + )} + + <> + {renders === 1 && loading2} + {renders === 2 && ( + <> + {data3} + {data4} + + )} + + {renders === 2 && done} + + ); +}; + +const Example2 = () => { + const renders = useRenders(2); + + return ( + + {renders === 2 && data1} + {data2} + {renders === 1 && data3} + {renders === 2 && done} + + ); +}; + +function App({api}: {api: RenderAPI}) { + if (api.example === 'react-mutations-1') { + return ; + } + + if (api.example === 'react-mutations-2') { + return ; + } +} + +export function renderUsingReact(root: Element, api: RenderAPI) { + createRoot(root).render(); +} diff --git a/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts b/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts new file mode 100644 index 00000000..2771aafa --- /dev/null +++ b/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts @@ -0,0 +1,33 @@ +import {useEffect, useState} from 'react'; + +export const useTimer = (ms: number) => { + const [ellapsed, setEllapsed] = useState(false); + useEffect(() => { + const timeout = setTimeout(() => { + setEllapsed(true); + }, ms); + + return () => { + clearTimeout(timeout); + }; + }, [setEllapsed]); + return ellapsed; +}; + +export const useRenders = (max: number) => { + const [renders, setRenders] = useState(1); + + useEffect(() => { + if (renders >= max) { + return; + } + + const timeout = setTimeout(() => { + setRenders((r) => r + 1); + }, 200); + + return () => clearTimeout(timeout); + }, [setRenders, renders]); + + return renders; +}; diff --git a/examples/kitchen-sink/app/remote/render.ts b/examples/kitchen-sink/app/remote/render.ts index 42647656..cae47a02 100644 --- a/examples/kitchen-sink/app/remote/render.ts +++ b/examples/kitchen-sink/app/remote/render.ts @@ -22,6 +22,11 @@ export async function render(root: Element, api: RenderAPI) { const {renderUsingReact} = await import('./examples/react.tsx'); return renderUsingReact(root, api); } + case 'react-mutations-1': + case 'react-mutations-2': { + const {renderUsingReact} = await import('./examples/react-mutations.tsx'); + return renderUsingReact(root, api); + } case 'svelte': { const {renderUsingSvelte} = await import('./examples/svelte.ts'); return renderUsingSvelte(root, api); diff --git a/examples/kitchen-sink/app/types.ts b/examples/kitchen-sink/app/types.ts index ea78dd21..a8eab973 100644 --- a/examples/kitchen-sink/app/types.ts +++ b/examples/kitchen-sink/app/types.ts @@ -25,9 +25,12 @@ export type RenderExample = | 'htm' | 'preact' | 'react' + | 'react-mutations' | 'svelte' | 'vue' - | 'react-remote-ui'; + | 'react-remote-ui' + | 'react-mutations-1' + | 'react-mutations-2'; /** * The object that the “host” page will pass to the “remote” environment. This @@ -123,4 +126,6 @@ export interface StackProperties { * Whether children should have space between them. */ spacing?: boolean; + + testId?: string; } From 548de645cf584adeb3dc824ad31cda80fed4d191 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 3 Apr 2025 14:10:52 +0200 Subject: [PATCH 03/10] fix skip condition --- packages/core/source/elements/RemoteMutationObserver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/source/elements/RemoteMutationObserver.ts b/packages/core/source/elements/RemoteMutationObserver.ts index 4c0bdb67..0d2bc90b 100644 --- a/packages/core/source/elements/RemoteMutationObserver.ts +++ b/packages/core/source/elements/RemoteMutationObserver.ts @@ -56,7 +56,7 @@ export class RemoteMutationObserver extends MutationObserver { record.addedNodes.forEach((node) => { if ( addedNodes.some( - (added) => addedNodes.includes(node) || added.contains(node), + (addedNode) => addedNode === node || addedNode.contains(node), ) ) { // A mutation observer will queue some changes, so we might get one record @@ -132,6 +132,7 @@ export class RemoteMutationObserver extends MutationObserver { MUTATION_TYPE_INSERT_CHILD, ROOT_ID, serializeRemoteNode(node), + undefined, ]); } From dcdc32ab7fde4158ca7404a53309e85f6fb4ac4c Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 3 Apr 2025 14:11:28 +0200 Subject: [PATCH 04/10] make siblingId parameter required for more consistency --- packages/compat/source/adapter/host.ts | 2 ++ packages/compat/source/tests/adapter.test.ts | 9 +++++++++ packages/core/source/elements/RemoteRootElement.ts | 1 + packages/core/source/tests/elements.test.ts | 2 ++ packages/core/source/types.ts | 2 +- 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/compat/source/adapter/host.ts b/packages/compat/source/adapter/host.ts index e76cf39e..eabaa1d7 100644 --- a/packages/compat/source/adapter/host.ts +++ b/packages/compat/source/adapter/host.ts @@ -165,6 +165,7 @@ export function adaptToLegacyRemoteChannel( MUTATION_TYPE_INSERT_CHILD, ROOT_ID, adaptLegacyNodeSerialization(node, options), + undefined, ] satisfies RemoteMutationRecord, ); @@ -254,6 +255,7 @@ export function adaptToLegacyRemoteChannel( MUTATION_TYPE_INSERT_CHILD, id, adaptLegacyPropFragmentSerialization(key, value, options), + undefined, ] satisfies RemoteMutationRecord); } else { if (slotNodeId !== undefined) { diff --git a/packages/compat/source/tests/adapter.test.ts b/packages/compat/source/tests/adapter.test.ts index 9c4c1127..aa47475a 100644 --- a/packages/compat/source/tests/adapter.test.ts +++ b/packages/compat/source/tests/adapter.test.ts @@ -52,6 +52,7 @@ describe('adaptToLegacyRemoteChannel()', () => { type: NODE_TYPE_TEXT, data: 'I am a text', }, + undefined, ], ]); @@ -97,6 +98,7 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, + undefined, ], ]); @@ -149,6 +151,7 @@ describe('adaptToLegacyRemoteChannel()', () => { type: NODE_TYPE_COMMENT, data: 'added by remote-ui legacy adaptor to replace a fragment rendered as a child', }, + undefined, ], ]); @@ -223,6 +226,7 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, + undefined, ], ]); @@ -310,6 +314,7 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, + undefined, ], ]); @@ -444,6 +449,7 @@ describe('adaptToLegacyRemoteChannel()', () => { ], properties: {}, }, + undefined, ], ]); @@ -1028,6 +1034,7 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, + undefined, ], ]); @@ -1095,6 +1102,7 @@ describe('adaptToLegacyRemoteChannel()', () => { properties: {}, children: [], }, + undefined, ], ]); }); @@ -1159,6 +1167,7 @@ describe('adaptToLegacyRemoteChannel()', () => { }, ], }, + undefined, ], ]); diff --git a/packages/core/source/elements/RemoteRootElement.ts b/packages/core/source/elements/RemoteRootElement.ts index a46d253d..c1734e41 100644 --- a/packages/core/source/elements/RemoteRootElement.ts +++ b/packages/core/source/elements/RemoteRootElement.ts @@ -54,6 +54,7 @@ export class RemoteRootElement extends HTMLElement { MUTATION_TYPE_INSERT_CHILD, ROOT_ID, serializeRemoteNode(node), + undefined, ]); } diff --git a/packages/core/source/tests/elements.test.ts b/packages/core/source/tests/elements.test.ts index b34c7d9f..7348d026 100644 --- a/packages/core/source/tests/elements.test.ts +++ b/packages/core/source/tests/elements.test.ts @@ -783,6 +783,7 @@ describe('RemoteElement', () => { expect.objectContaining({ attributes: {name}, }), + undefined, ], ]); }); @@ -808,6 +809,7 @@ describe('RemoteElement', () => { expect.objectContaining({ attributes: {slot}, }), + undefined, ], ]); }); diff --git a/packages/core/source/types.ts b/packages/core/source/types.ts index 556587a9..6c940cc6 100644 --- a/packages/core/source/types.ts +++ b/packages/core/source/types.ts @@ -33,7 +33,7 @@ export type RemoteMutationRecordInsertChild = [ /** * The index in the parents’ children to insert the new child. */ - nextSiblingId?: string, + nextSiblingId: string | undefined, ]; /** From 7127a43133d3fde95bfd517a3bee9e7848fc396b Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 3 Apr 2025 14:13:35 +0200 Subject: [PATCH 05/10] remove unused utils function in kitchen-sink demo --- .../app/remote/examples/utils/react-hooks.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts b/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts index 2771aafa..3816e512 100644 --- a/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts +++ b/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts @@ -1,18 +1,4 @@ -import {useEffect, useState} from 'react'; - -export const useTimer = (ms: number) => { - const [ellapsed, setEllapsed] = useState(false); - useEffect(() => { - const timeout = setTimeout(() => { - setEllapsed(true); - }, ms); - - return () => { - clearTimeout(timeout); - }; - }, [setEllapsed]); - return ellapsed; -}; +import { useEffect, useState } from 'react'; export const useRenders = (max: number) => { const [renders, setRenders] = useState(1); From 3aa9bd1ca524b7c2ceee863b1d93f3ba91c2f733 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 3 Apr 2025 16:02:58 +0200 Subject: [PATCH 06/10] add changeset --- .changeset/clever-glasses-hug.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/clever-glasses-hug.md diff --git a/.changeset/clever-glasses-hug.md b/.changeset/clever-glasses-hug.md new file mode 100644 index 00000000..b80857ed --- /dev/null +++ b/.changeset/clever-glasses-hug.md @@ -0,0 +1,9 @@ +--- +'example-kitchen-sink': patch +'@remote-dom/polyfill': patch +'@remote-dom/signals': patch +'@remote-dom/compat': patch +'@remote-dom/core': patch +--- + +Fix elements are inserted or removed at incorrect positions, leading to UI inconsistencies. From ca9befe131111d70d218f4e2f5336fec01794788 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Mon, 7 Apr 2025 11:44:21 +0200 Subject: [PATCH 07/10] fix edge case with duplicate nodes on host side --- e2e/mutations.e2e.ts | 5 +- examples/kitchen-sink/app/host/components.tsx | 2 + examples/kitchen-sink/app/host/state.ts | 1 + .../app/remote/examples/react-mutations.tsx | 59 +++++++++++++++---- examples/kitchen-sink/app/remote/render.ts | 3 +- examples/kitchen-sink/app/types.ts | 3 +- packages/compat/source/adapter/host.ts | 5 ++ .../source/elements/RemoteMutationObserver.ts | 17 ------ .../source/receivers/DOMRemoteReceiver.ts | 4 ++ .../core/source/receivers/RemoteReceiver.ts | 5 ++ .../signals/source/SignalRemoteReceiver.ts | 16 ++--- 11 files changed, 80 insertions(+), 40 deletions(-) diff --git a/e2e/mutations.e2e.ts b/e2e/mutations.e2e.ts index 1350b695..f19364a3 100644 --- a/e2e/mutations.e2e.ts +++ b/e2e/mutations.e2e.ts @@ -1,8 +1,9 @@ -import {expect, test} from '@playwright/test'; +import { expect, test } from '@playwright/test'; const testSet: Array<[string, string, string]> = [ ['iframe', 'react-mutations-1', 'Data: 1\nData: 2\nData: 3\nData: 4'], - ['iframe', 'react-mutations-2', 'Data: 1\nData: 2'], + ['iframe', 'react-mutations-2', 'Data: 1\nData: 2\nData: 3\nData: 4'], + ['iframe', 'react-mutations-3', 'Data: 1\nData: 2'], ]; testSet.forEach(([sandbox, example, expectedText]) => { diff --git a/examples/kitchen-sink/app/host/components.tsx b/examples/kitchen-sink/app/host/components.tsx index 98748a71..81d14906 100644 --- a/examples/kitchen-sink/app/host/components.tsx +++ b/examples/kitchen-sink/app/host/components.tsx @@ -164,6 +164,7 @@ export function ControlPanel({ + @@ -200,6 +201,7 @@ const EXAMPLE_FILE_NAMES = new Map([ ['react', 'react.tsx'], ['react-mutations-1', 'react-mutations.tsx'], ['react-mutations-2', 'react-mutations.tsx'], + ['react-mutations-3', 'react-mutations.tsx'], ['svelte', 'App.svelte'], ['vue', 'App.vue'], ]); diff --git a/examples/kitchen-sink/app/host/state.ts b/examples/kitchen-sink/app/host/state.ts index 5321f1b2..da6850c8 100644 --- a/examples/kitchen-sink/app/host/state.ts +++ b/examples/kitchen-sink/app/host/state.ts @@ -17,6 +17,7 @@ const ALLOWED_EXAMPLE_VALUES = new Set([ 'vue', 'react-mutations-1', 'react-mutations-2', + 'react-mutations-3', ]); export function createState( diff --git a/examples/kitchen-sink/app/remote/examples/react-mutations.tsx b/examples/kitchen-sink/app/remote/examples/react-mutations.tsx index d8c70cd5..a2cd92f5 100644 --- a/examples/kitchen-sink/app/remote/examples/react-mutations.tsx +++ b/examples/kitchen-sink/app/remote/examples/react-mutations.tsx @@ -1,12 +1,13 @@ /** @jsxRuntime automatic */ /** @jsxImportSource react */ -import {createRemoteComponent} from '@remote-dom/react'; -import {createRoot} from 'react-dom/client'; +import { createRemoteComponent } from '@remote-dom/react'; +import { createRoot } from 'react-dom/client'; -import type {RenderAPI} from '../../types.ts'; -import {Stack as StackElement, Text as TextElement} from '../elements.ts'; -import {useRenders} from './utils/react-hooks.ts'; +import { useEffect, useState } from 'react'; +import type { RenderAPI } from '../../types.ts'; +import { Stack as StackElement, Text as TextElement } from '../elements.ts'; +import { useRenders } from './utils/react-hooks.ts'; const Stack = createRemoteComponent('ui-stack', StackElement); const Text = createRemoteComponent('ui-text', TextElement); @@ -48,6 +49,39 @@ const Example1 = () => { }; const Example2 = () => { + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(false); + }, [setLoading]); + + return ( + + <> + {loading && loading1} + {!loading && ( + <> + {data1} + {data2} + + )} + + <> + {loading && loading2} + {!loading && ( + <> + {data3} + {data4} + + )} + + {!loading && done} + + ); +}; + + +const Example3 = () => { const renders = useRenders(2); return ( @@ -60,14 +94,17 @@ const Example2 = () => { ); }; + function App({api}: {api: RenderAPI}) { - if (api.example === 'react-mutations-1') { - return ; - } + const {example} = api; - if (api.example === 'react-mutations-2') { - return ; - } + return example === 'react-mutations-1' ? ( + + ) : example === 'react-mutations-2' ? ( + + ) : example === 'react-mutations-3' ? ( + + ) : null; } export function renderUsingReact(root: Element, api: RenderAPI) { diff --git a/examples/kitchen-sink/app/remote/render.ts b/examples/kitchen-sink/app/remote/render.ts index cae47a02..72e53911 100644 --- a/examples/kitchen-sink/app/remote/render.ts +++ b/examples/kitchen-sink/app/remote/render.ts @@ -23,7 +23,8 @@ export async function render(root: Element, api: RenderAPI) { return renderUsingReact(root, api); } case 'react-mutations-1': - case 'react-mutations-2': { + case 'react-mutations-2': + case 'react-mutations-3': { const {renderUsingReact} = await import('./examples/react-mutations.tsx'); return renderUsingReact(root, api); } diff --git a/examples/kitchen-sink/app/types.ts b/examples/kitchen-sink/app/types.ts index a8eab973..2b43cdcd 100644 --- a/examples/kitchen-sink/app/types.ts +++ b/examples/kitchen-sink/app/types.ts @@ -30,7 +30,8 @@ export type RenderExample = | 'vue' | 'react-remote-ui' | 'react-mutations-1' - | 'react-mutations-2'; + | 'react-mutations-2' + | 'react-mutations-3'; /** * The object that the “host” page will pass to the “remote” environment. This diff --git a/packages/compat/source/adapter/host.ts b/packages/compat/source/adapter/host.ts index eabaa1d7..0dd88085 100644 --- a/packages/compat/source/adapter/host.ts +++ b/packages/compat/source/adapter/host.ts @@ -87,6 +87,11 @@ export function adaptToLegacyRemoteChannel( } const siblings = tree.get(parentId)!; + + if (siblings.some((existing) => existing.id === node.id)) { + return; + } + const index = nextSiblingId === undefined ? siblings.length diff --git a/packages/core/source/elements/RemoteMutationObserver.ts b/packages/core/source/elements/RemoteMutationObserver.ts index 0d2bc90b..4e984381 100644 --- a/packages/core/source/elements/RemoteMutationObserver.ts +++ b/packages/core/source/elements/RemoteMutationObserver.ts @@ -34,7 +34,6 @@ import { export class RemoteMutationObserver extends MutationObserver { constructor(private readonly connection: RemoteConnection) { super((records) => { - const addedNodes: Node[] = []; const remoteRecords: RemoteMutationRecord[] = []; for (const record of records) { @@ -49,25 +48,9 @@ export class RemoteMutationObserver extends MutationObserver { targetId, remoteId(node), ]); - - addedNodes.splice(addedNodes.indexOf(node), 1); }); record.addedNodes.forEach((node) => { - if ( - addedNodes.some( - (addedNode) => addedNode === node || addedNode.contains(node), - ) - ) { - // A mutation observer will queue some changes, so we might get one record - // for attaching a parent element, and additional records for attaching descendants. - // We serialize the entire tree when a new node was added, so we don’t want to - // send additional “insert child” records when we see those descendants — they - // will already be included the insertion of the parent. - return; - } - - addedNodes.push(node); connectRemoteNode(node, connection); remoteRecords.push([ diff --git a/packages/core/source/receivers/DOMRemoteReceiver.ts b/packages/core/source/receivers/DOMRemoteReceiver.ts index 20e1dce5..00dd5359 100644 --- a/packages/core/source/receivers/DOMRemoteReceiver.ts +++ b/packages/core/source/receivers/DOMRemoteReceiver.ts @@ -110,6 +110,10 @@ export class DOMRemoteReceiver { parentId === ROOT_ID ? this.root : attached.get(parentId)!; const normalizedChild = attach(child); + if (parent.contains(normalizedChild)) { + return; + } + const existingTimeout = destroyTimeouts.get(parentId); if (existingTimeout) clearTimeout(existingTimeout); diff --git a/packages/core/source/receivers/RemoteReceiver.ts b/packages/core/source/receivers/RemoteReceiver.ts index 8da39d86..a541331b 100644 --- a/packages/core/source/receivers/RemoteReceiver.ts +++ b/packages/core/source/receivers/RemoteReceiver.ts @@ -165,6 +165,11 @@ export class RemoteReceiver { insertChild: (parentId, child, nextSiblingId) => { const parent = attached.get(parentId) as Writable; const children = parent.children as Writable; + + if (children.some((existing) => existing.id === child.id)) { + return; + } + const normalizedChild = attach(child, parent); if (nextSiblingId === undefined) { diff --git a/packages/signals/source/SignalRemoteReceiver.ts b/packages/signals/source/SignalRemoteReceiver.ts index e5f612e7..704aeb48 100644 --- a/packages/signals/source/SignalRemoteReceiver.ts +++ b/packages/signals/source/SignalRemoteReceiver.ts @@ -160,24 +160,24 @@ export class SignalRemoteReceiver { }, insertChild: (parentId, child, nextSiblingId) => { const parent = attached.get(parentId) as SignalRemoteReceiverParent; - const newChildren = [...parent.children.peek()]; + const children = [...parent.children.peek()]; + + if (children.some((existing) => existing.id === child.id)) { + return; + } const normalizedChild = attach(child, parent); if (nextSiblingId === undefined) { - newChildren.push(normalizedChild); + children.push(normalizedChild); } else { const nextSibling = attached.get( nextSiblingId, ) as SignalRemoteReceiverNode; - newChildren.splice( - newChildren.indexOf(nextSibling), - 0, - normalizedChild, - ); + children.splice(children.indexOf(nextSibling), 0, normalizedChild); } - (parent.children as any).value = newChildren; + (parent.children as any).value = children; }, removeChild: (parentId, id) => { const parent = attached.get(parentId) as SignalRemoteReceiverParent; From 57b17df434d1fe7ea4a3adbc6a161f85a287cb41 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Tue, 8 Apr 2025 09:50:26 +0200 Subject: [PATCH 08/10] minor code improvements --- examples/kitchen-sink/app/host/state.ts | 1 - packages/compat/source/adapter/host.ts | 69 ++++++++++++++----------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/examples/kitchen-sink/app/host/state.ts b/examples/kitchen-sink/app/host/state.ts index da6850c8..fb99a9d8 100644 --- a/examples/kitchen-sink/app/host/state.ts +++ b/examples/kitchen-sink/app/host/state.ts @@ -12,7 +12,6 @@ const ALLOWED_EXAMPLE_VALUES = new Set([ 'htm', 'preact', 'react', - 'react-mutations', 'svelte', 'vue', 'react-mutations-1', diff --git a/packages/compat/source/adapter/host.ts b/packages/compat/source/adapter/host.ts index 0dd88085..748bd5f5 100644 --- a/packages/compat/source/adapter/host.ts +++ b/packages/compat/source/adapter/host.ts @@ -70,8 +70,22 @@ export function adaptToLegacyRemoteChannel( connection: RemoteConnection, options?: LegacyRemoteChannelOptions, ): LegacyRemoteChannel { + type TreeEntry = {id: string; slot?: string}; + // child node list of a given parent - const tree = new Map(); + const tree = new Map(); + + function getSiblings(parentId: string) { + const siblings = tree.get(parentId); + + if (siblings === undefined) { + const newSiblings: TreeEntry[] = []; + tree.set(parentId, newSiblings); + return newSiblings; + } + + return siblings; + } function mutate(records: RemoteMutationRecord[]) { for (const record of records) { @@ -79,14 +93,8 @@ export function adaptToLegacyRemoteChannel( switch (mutationType) { case MUTATION_TYPE_INSERT_CHILD: { - const parentId = record[1]; - const node = record[2]; - const nextSiblingId = record[3]; - if (!tree.has(parentId)) { - tree.set(parentId, []); - } - - const siblings = tree.get(parentId)!; + const [, parentId, node, nextSiblingId] = record; + const siblings = getSiblings(parentId); if (siblings.some((existing) => existing.id === node.id)) { return; @@ -100,8 +108,8 @@ export function adaptToLegacyRemoteChannel( break; } case MUTATION_TYPE_REMOVE_CHILD: { - const index = record[2]; - removeNode(parentId, index); + const id = record[2]; + removeNode(parentId, id); break; } } @@ -115,10 +123,7 @@ export function adaptToLegacyRemoteChannel( node: RemoteNodeSerialization, index: number, ) { - if (!tree.has(parentId)) { - tree.set(parentId, []); - } - const siblings = tree.get(parentId)!; + const siblings = getSiblings(parentId); siblings.splice(index, 0, { id: node.id, slot: 'attributes' in node ? node.attributes?.slot : undefined, @@ -132,12 +137,11 @@ export function adaptToLegacyRemoteChannel( } function removeNode(parentId: string, id: string) { - const siblings = tree.get(parentId); - if (!siblings) { + const siblings = getSiblings(parentId); + const index = siblings.findIndex((child) => child.id === id); + if (index === -1) { return; } - const index = siblings?.findIndex((child) => child.id === id); - if (index === -1) return; siblings.splice(index, 1); cleanupNode(id); @@ -185,13 +189,14 @@ export function adaptToLegacyRemoteChannel( const records = []; - const siblings = tree.get(parentId) ?? []; - const relevantSiblings = [...siblings]; + const siblings = getSiblings(parentId); - const existingChildIndex = relevantSiblings.findIndex( + const existingChildIndex = siblings.findIndex( ({id}) => id === child.id, ); + let nextSiblingIndex = index; + if (existingChildIndex >= 0) { records.push([ MUTATION_TYPE_REMOVE_CHILD, @@ -199,10 +204,12 @@ export function adaptToLegacyRemoteChannel( child.id, ] satisfies RemoteMutationRecord); - relevantSiblings.splice(existingChildIndex, 1); + if (existingChildIndex <= index) { + nextSiblingIndex++; + } } - const nextSibling = relevantSiblings[index]; + const nextSibling = siblings[nextSiblingIndex]; records.push([ MUTATION_TYPE_INSERT_CHILD, @@ -238,27 +245,27 @@ export function adaptToLegacyRemoteChannel( } case LEGACY_ACTION_UPDATE_PROPS: { - const [id, props] = + const [parentId, props] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_UPDATE_PROPS]; - const siblings = tree.get(id); + const siblings = getSiblings(parentId); const records = []; for (const [key, value] of Object.entries(props)) { - const slotNodeId = siblings?.find(({slot}) => slot === key)?.id; + const slotNodeId = siblings.find(({slot}) => slot === key)?.id; if (isFragment(value)) { if (slotNodeId !== undefined) { records.push([ MUTATION_TYPE_REMOVE_CHILD, - id, + parentId, slotNodeId, ] satisfies RemoteMutationRecord); } records.push([ MUTATION_TYPE_INSERT_CHILD, - id, + parentId, adaptLegacyPropFragmentSerialization(key, value, options), undefined, ] satisfies RemoteMutationRecord); @@ -266,13 +273,13 @@ export function adaptToLegacyRemoteChannel( if (slotNodeId !== undefined) { records.push([ MUTATION_TYPE_REMOVE_CHILD, - id, + parentId, slotNodeId, ] satisfies RemoteMutationRecord); } else { records.push([ MUTATION_TYPE_UPDATE_PROPERTY, - id, + parentId, key, value, ] satisfies RemoteMutationRecord); From a84d4a363eb72554500a9c7088e361628becdbb1 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 24 Apr 2025 11:06:36 +0200 Subject: [PATCH 09/10] reset import code formattings --- e2e/mutations.e2e.ts | 2 +- examples/kitchen-sink/app/host/components.tsx | 10 +++---- examples/kitchen-sink/app/host/state.ts | 2 +- examples/kitchen-sink/app/remote/elements.ts | 6 ++-- .../app/remote/examples/react-mutations.tsx | 14 ++++------ .../app/remote/examples/utils/react-hooks.ts | 2 +- packages/compat/source/adapter/host.ts | 28 +++++++++---------- packages/compat/source/tests/adapter.test.ts | 2 +- .../source/elements/RemoteMutationObserver.ts | 18 ++++++------ .../core/source/elements/RemoteRootElement.ts | 8 +++--- packages/core/source/polyfill.ts | 6 ++-- .../source/receivers/DOMRemoteReceiver.ts | 4 +-- .../core/source/receivers/RemoteReceiver.ts | 2 +- packages/core/source/tests/elements.test.ts | 20 ++++++------- packages/core/source/types.ts | 12 ++++---- packages/polyfill/source/ParentNode.ts | 14 +++++----- .../signals/source/SignalRemoteReceiver.ts | 14 +++++----- 17 files changed, 81 insertions(+), 83 deletions(-) diff --git a/e2e/mutations.e2e.ts b/e2e/mutations.e2e.ts index f19364a3..ef94313d 100644 --- a/e2e/mutations.e2e.ts +++ b/e2e/mutations.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import {expect, test} from '@playwright/test'; const testSet: Array<[string, string, string]> = [ ['iframe', 'react-mutations-1', 'Data: 1\nData: 2\nData: 3\nData: 4'], diff --git a/examples/kitchen-sink/app/host/components.tsx b/examples/kitchen-sink/app/host/components.tsx index 81d14906..e24f4dd4 100644 --- a/examples/kitchen-sink/app/host/components.tsx +++ b/examples/kitchen-sink/app/host/components.tsx @@ -1,16 +1,16 @@ -import type {Signal} from '@preact/signals'; import {type ComponentChildren} from 'preact'; import {forwardRef} from 'preact/compat'; -import {useImperativeHandle, useRef} from 'preact/hooks'; +import {useRef, useImperativeHandle} from 'preact/hooks'; +import type {Signal} from '@preact/signals'; import type { ButtonProperties, + StackProperties, + TextProperties, ModalMethods, ModalProperties, - RenderExample, RenderSandbox, - StackProperties, - TextProperties, + RenderExample, } from '../types.ts'; export function Text({ diff --git a/examples/kitchen-sink/app/host/state.ts b/examples/kitchen-sink/app/host/state.ts index fb99a9d8..4de6f46f 100644 --- a/examples/kitchen-sink/app/host/state.ts +++ b/examples/kitchen-sink/app/host/state.ts @@ -1,4 +1,4 @@ -import {effect, signal} from '@preact/signals'; +import {signal, effect} from '@preact/signals'; import {SignalRemoteReceiver} from '@remote-dom/preact/host'; import type {RenderExample, RenderSandbox} from '../types.ts'; diff --git a/examples/kitchen-sink/app/remote/elements.ts b/examples/kitchen-sink/app/remote/elements.ts index a3c1b16a..11d72460 100644 --- a/examples/kitchen-sink/app/remote/elements.ts +++ b/examples/kitchen-sink/app/remote/elements.ts @@ -1,16 +1,16 @@ import { createRemoteElement, - RemoteFragmentElement, RemoteRootElement, + RemoteFragmentElement, type RemoteEvent, } from '@remote-dom/core/elements'; import type { + TextProperties, ButtonProperties, - ModalMethods, ModalProperties, + ModalMethods, StackProperties, - TextProperties, } from '../types.ts'; // In this file we will define the custom elements that can be rendered in the diff --git a/examples/kitchen-sink/app/remote/examples/react-mutations.tsx b/examples/kitchen-sink/app/remote/examples/react-mutations.tsx index a2cd92f5..f16b9ae4 100644 --- a/examples/kitchen-sink/app/remote/examples/react-mutations.tsx +++ b/examples/kitchen-sink/app/remote/examples/react-mutations.tsx @@ -1,13 +1,13 @@ /** @jsxRuntime automatic */ /** @jsxImportSource react */ -import { createRemoteComponent } from '@remote-dom/react'; -import { createRoot } from 'react-dom/client'; +import {createRemoteComponent} from '@remote-dom/react'; +import {createRoot} from 'react-dom/client'; -import { useEffect, useState } from 'react'; -import type { RenderAPI } from '../../types.ts'; -import { Stack as StackElement, Text as TextElement } from '../elements.ts'; -import { useRenders } from './utils/react-hooks.ts'; +import {useEffect, useState} from 'react'; +import type {RenderAPI} from '../../types.ts'; +import {Stack as StackElement, Text as TextElement} from '../elements.ts'; +import {useRenders} from './utils/react-hooks.ts'; const Stack = createRemoteComponent('ui-stack', StackElement); const Text = createRemoteComponent('ui-text', TextElement); @@ -80,7 +80,6 @@ const Example2 = () => { ); }; - const Example3 = () => { const renders = useRenders(2); @@ -94,7 +93,6 @@ const Example3 = () => { ); }; - function App({api}: {api: RenderAPI}) { const {example} = api; diff --git a/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts b/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts index 3816e512..b91f583a 100644 --- a/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts +++ b/examples/kitchen-sink/app/remote/examples/utils/react-hooks.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import {useEffect, useState} from 'react'; export const useRenders = (max: number) => { const [renders, setRenders] = useState(1); diff --git a/packages/compat/source/adapter/host.ts b/packages/compat/source/adapter/host.ts index 748bd5f5..8c639da1 100644 --- a/packages/compat/source/adapter/host.ts +++ b/packages/compat/source/adapter/host.ts @@ -1,34 +1,34 @@ import { - ACTION_INSERT_CHILD as LEGACY_ACTION_INSERT_CHILD, + KIND_TEXT as LEGACY_KIND_TEXT, + KIND_COMPONENT as LEGACY_KIND_COMPONENT, + KIND_FRAGMENT as LEGACY_KIND_FRAGMENT, ACTION_MOUNT as LEGACY_ACTION_MOUNT, + ACTION_INSERT_CHILD as LEGACY_ACTION_INSERT_CHILD, ACTION_REMOVE_CHILD as LEGACY_ACTION_REMOVE_CHILD, ACTION_UPDATE_PROPS as LEGACY_ACTION_UPDATE_PROPS, ACTION_UPDATE_TEXT as LEGACY_ACTION_UPDATE_TEXT, - KIND_COMPONENT as LEGACY_KIND_COMPONENT, - KIND_FRAGMENT as LEGACY_KIND_FRAGMENT, - KIND_TEXT as LEGACY_KIND_TEXT, - type ActionArgumentMap as LegacyActionArgumentMap, type RemoteChannel as LegacyRemoteChannel, + type ActionArgumentMap as LegacyActionArgumentMap, type RemoteComponentSerialization as LegacyRemoteComponentSerialization, - type RemoteFragmentSerialization as LegacyRemoteFragmentSerialization, type RemoteTextSerialization as LegacyRemoteTextSerialization, + type RemoteFragmentSerialization as LegacyRemoteFragmentSerialization, } from '@remote-ui/core'; import { + ROOT_ID, + NODE_TYPE_TEXT, + NODE_TYPE_COMMENT, + NODE_TYPE_ELEMENT, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, - NODE_TYPE_COMMENT, - NODE_TYPE_ELEMENT, - NODE_TYPE_TEXT, - ROOT_ID, - type RemoteCommentSerialization, - type RemoteConnection, - type RemoteElementSerialization, type RemoteMutationRecord, - type RemoteNodeSerialization, type RemoteTextSerialization, + type RemoteElementSerialization, + type RemoteConnection, + type RemoteNodeSerialization, + type RemoteCommentSerialization, } from '@remote-dom/core'; export interface LegacyRemoteChannelElementMap { diff --git a/packages/compat/source/tests/adapter.test.ts b/packages/compat/source/tests/adapter.test.ts index aa47475a..ab0e3945 100644 --- a/packages/compat/source/tests/adapter.test.ts +++ b/packages/compat/source/tests/adapter.test.ts @@ -20,9 +20,9 @@ import { MUTATION_TYPE_REMOVE_CHILD, MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, - NODE_TYPE_COMMENT, NODE_TYPE_ELEMENT, NODE_TYPE_TEXT, + NODE_TYPE_COMMENT, ROOT_ID, } from '@remote-dom/core'; diff --git a/packages/core/source/elements/RemoteMutationObserver.ts b/packages/core/source/elements/RemoteMutationObserver.ts index 4e984381..c4985129 100644 --- a/packages/core/source/elements/RemoteMutationObserver.ts +++ b/packages/core/source/elements/RemoteMutationObserver.ts @@ -1,18 +1,18 @@ import { + remoteId, + connectRemoteNode, + disconnectRemoteNode, + serializeRemoteNode, + REMOTE_IDS, +} from './internals.ts'; +import { + ROOT_ID, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, - MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, - ROOT_ID, + MUTATION_TYPE_UPDATE_PROPERTY, } from '../constants.ts'; import type {RemoteConnection, RemoteMutationRecord} from '../types.ts'; -import { - connectRemoteNode, - disconnectRemoteNode, - REMOTE_IDS, - remoteId, - serializeRemoteNode, -} from './internals.ts'; /** * Builds on the browser’s [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) diff --git a/packages/core/source/elements/RemoteRootElement.ts b/packages/core/source/elements/RemoteRootElement.ts index c1734e41..10a1bcaa 100644 --- a/packages/core/source/elements/RemoteRootElement.ts +++ b/packages/core/source/elements/RemoteRootElement.ts @@ -1,13 +1,13 @@ -import {MUTATION_TYPE_INSERT_CHILD, ROOT_ID} from '../constants.ts'; +import {ROOT_ID, MUTATION_TYPE_INSERT_CHILD} from '../constants.ts'; import type {RemoteConnection, RemoteMutationRecord} from '../types.ts'; import { - callRemoteElementMethod, - connectRemoteNode, - REMOTE_IDS, remoteConnection, + connectRemoteNode, serializeRemoteNode, updateRemoteElementProperty, + callRemoteElementMethod, + REMOTE_IDS, } from './internals.ts'; /** diff --git a/packages/core/source/polyfill.ts b/packages/core/source/polyfill.ts index fa105702..0f37c50d 100644 --- a/packages/core/source/polyfill.ts +++ b/packages/core/source/polyfill.ts @@ -1,4 +1,4 @@ -import {HOOKS, Window, type Hooks} from '@remote-dom/polyfill'; +import {Window, HOOKS, type Hooks} from '@remote-dom/polyfill'; import { MUTATION_TYPE_INSERT_CHILD, @@ -6,10 +6,10 @@ import { MUTATION_TYPE_UPDATE_TEXT, } from './constants.ts'; import { + remoteId, + remoteConnection, connectRemoteNode, disconnectRemoteNode, - remoteConnection, - remoteId, serializeRemoteNode, updateRemoteElementAttribute, } from './elements/internals.ts'; diff --git a/packages/core/source/receivers/DOMRemoteReceiver.ts b/packages/core/source/receivers/DOMRemoteReceiver.ts index 00dd5359..08f29f66 100644 --- a/packages/core/source/receivers/DOMRemoteReceiver.ts +++ b/packages/core/source/receivers/DOMRemoteReceiver.ts @@ -1,12 +1,12 @@ import {createRemoteConnection, type RemoteConnection} from '../connection.ts'; import { + NODE_TYPE_TEXT, NODE_TYPE_COMMENT, NODE_TYPE_ELEMENT, - NODE_TYPE_TEXT, ROOT_ID, + UPDATE_PROPERTY_TYPE_PROPERTY, UPDATE_PROPERTY_TYPE_ATTRIBUTE, UPDATE_PROPERTY_TYPE_EVENT_LISTENER, - UPDATE_PROPERTY_TYPE_PROPERTY, } from '../constants.ts'; import type {RemoteNodeSerialization} from '../types.ts'; import type {RemoteReceiverOptions} from './shared.ts'; diff --git a/packages/core/source/receivers/RemoteReceiver.ts b/packages/core/source/receivers/RemoteReceiver.ts index a541331b..48e7627a 100644 --- a/packages/core/source/receivers/RemoteReceiver.ts +++ b/packages/core/source/receivers/RemoteReceiver.ts @@ -10,10 +10,10 @@ import { UPDATE_PROPERTY_TYPE_PROPERTY, } from '../constants.ts'; import type { + RemoteTextSerialization, RemoteCommentSerialization, RemoteElementSerialization, RemoteNodeSerialization, - RemoteTextSerialization, } from '../types.ts'; import type {RemoteReceiverOptions} from './shared.ts'; diff --git a/packages/core/source/tests/elements.test.ts b/packages/core/source/tests/elements.test.ts index 7348d026..2dbd6f87 100644 --- a/packages/core/source/tests/elements.test.ts +++ b/packages/core/source/tests/elements.test.ts @@ -1,17 +1,9 @@ -import {describe, expect, it, vi, type MockedObject} from 'vitest'; import '../polyfill.ts'; +import {describe, it, expect, vi, type MockedObject} from 'vitest'; -import {NAME, OWNER_DOCUMENT} from '../../../polyfill/source/constants.ts'; import { - MUTATION_TYPE_INSERT_CHILD, - MUTATION_TYPE_UPDATE_PROPERTY, - UPDATE_PROPERTY_TYPE_ATTRIBUTE, - UPDATE_PROPERTY_TYPE_EVENT_LISTENER, - UPDATE_PROPERTY_TYPE_PROPERTY, -} from '../constants.ts'; -import { - createRemoteElement, RemoteElement, + createRemoteElement, RemoteEvent, // remoteProperties, // remoteProperty, @@ -23,6 +15,14 @@ import { RemoteReceiver, type RemoteReceiverElement, } from '../receivers/RemoteReceiver.ts'; +import { + MUTATION_TYPE_UPDATE_PROPERTY, + UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + MUTATION_TYPE_INSERT_CHILD, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, +} from '../constants.ts'; +import {NAME, OWNER_DOCUMENT} from '../../../polyfill/source/constants.ts'; describe('RemoteElement', () => { describe('properties', () => { diff --git a/packages/core/source/types.ts b/packages/core/source/types.ts index 6c940cc6..d311635e 100644 --- a/packages/core/source/types.ts +++ b/packages/core/source/types.ts @@ -1,14 +1,14 @@ import type { + NODE_TYPE_ELEMENT, + NODE_TYPE_TEXT, + NODE_TYPE_COMMENT, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, - MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, - NODE_TYPE_COMMENT, - NODE_TYPE_ELEMENT, - NODE_TYPE_TEXT, - UPDATE_PROPERTY_TYPE_ATTRIBUTE, - UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + MUTATION_TYPE_UPDATE_PROPERTY, UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, } from './constants.ts'; /** diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index 30448535..9ab94045 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -1,17 +1,17 @@ -import {ChildNode, toNode} from './ChildNode.ts'; import { CHILD, - HOOKS, - IS_CONNECTED, NEXT, - NodeType, - OWNER_DOCUMENT, - PARENT, PREV, + PARENT, + OWNER_DOCUMENT, + NodeType, + HOOKS, + IS_CONNECTED, } from './constants.ts'; import type {Node} from './Node.ts'; +import {ChildNode, toNode} from './ChildNode.ts'; import {NodeList} from './NodeList.ts'; -import {querySelector, querySelectorAll} from './selectors.ts'; +import {querySelectorAll, querySelector} from './selectors.ts'; import {selfAndDescendants} from './shared.ts'; export class ParentNode extends ChildNode { diff --git a/packages/signals/source/SignalRemoteReceiver.ts b/packages/signals/source/SignalRemoteReceiver.ts index 704aeb48..f7f17015 100644 --- a/packages/signals/source/SignalRemoteReceiver.ts +++ b/packages/signals/source/SignalRemoteReceiver.ts @@ -1,25 +1,25 @@ import { - batch, signal, + batch, type ReadonlySignal, type Signal, } from '@preact/signals-core'; import { - NODE_TYPE_COMMENT, - NODE_TYPE_ELEMENT, + ROOT_ID, NODE_TYPE_ROOT, + NODE_TYPE_ELEMENT, + NODE_TYPE_COMMENT, NODE_TYPE_TEXT, - ROOT_ID, + UPDATE_PROPERTY_TYPE_PROPERTY, UPDATE_PROPERTY_TYPE_ATTRIBUTE, UPDATE_PROPERTY_TYPE_EVENT_LISTENER, - UPDATE_PROPERTY_TYPE_PROPERTY, createRemoteConnection, - type RemoteCommentSerialization, type RemoteConnection, - type RemoteElementSerialization, type RemoteNodeSerialization, type RemoteTextSerialization, + type RemoteCommentSerialization, + type RemoteElementSerialization, } from '@remote-dom/core'; import type {RemoteReceiverOptions} from '@remote-dom/core/receivers'; From 757ece926bd797064be11ebe11ed91470c9c50ce Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Fri, 6 Jun 2025 10:09:09 +0200 Subject: [PATCH 10/10] fix edge case with wrong detected removed nodes --- .../core/source/elements/RemoteMutationObserver.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/source/elements/RemoteMutationObserver.ts b/packages/core/source/elements/RemoteMutationObserver.ts index c4985129..adeaed4f 100644 --- a/packages/core/source/elements/RemoteMutationObserver.ts +++ b/packages/core/source/elements/RemoteMutationObserver.ts @@ -41,6 +41,16 @@ export class RemoteMutationObserver extends MutationObserver { if (record.type === 'childList') { record.removedNodes.forEach((node) => { + if (!REMOTE_IDS.has(node)) { + /** + * This happens if the node was not recognized during the + * `serializeRemoteNode` of a (probably direct and extensive) + * previous mutation-record, when it was no longer in the DOM + * at that time of processing. + */ + return; + } + disconnectRemoteNode(node); remoteRecords.push([