From 0a58b3c6714e5d91a04e9870022eecabdf8b2d35 Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Sat, 20 Dec 2025 15:59:28 +0200 Subject: [PATCH] fix: add connectedMoveCallback to the WC implementation --- .../src/components/UIResourceRendererWC.tsx | 51 +++++++++++---- .../__tests__/UIResourceRendererWC.test.tsx | 64 +++++++++++++++++++ 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/sdks/typescript/client/src/components/UIResourceRendererWC.tsx b/sdks/typescript/client/src/components/UIResourceRendererWC.tsx index 0fd055a8..49182c04 100644 --- a/sdks/typescript/client/src/components/UIResourceRendererWC.tsx +++ b/sdks/typescript/client/src/components/UIResourceRendererWC.tsx @@ -69,17 +69,40 @@ export const UIResourceRendererWCWrapper: FC = (props ); }; -customElements.define( - 'ui-resource-renderer', - r2wc(UIResourceRendererWCWrapper, { - props: { - resource: 'json', - supportedContentTypes: 'json', - htmlProps: 'json', - remoteDomProps: 'json', - /* `onUIAction` is intentionally omitted as the WC implements its own event dispatching mechanism for UI actions. - * Consumers should listen for the `onUIAction` CustomEvent on the element instead of passing an `onUIAction` prop. - */ - }, - }), -); +// Get the base web component class from r2wc +const BaseUIResourceRendererWC = r2wc(UIResourceRendererWCWrapper, { + props: { + resource: 'json', + supportedContentTypes: 'json', + htmlProps: 'json', + remoteDomProps: 'json', + /* `onUIAction` is intentionally omitted as the WC implements its own event dispatching mechanism for UI actions. + * Consumers should listen for the `onUIAction` CustomEvent on the element instead of passing an `onUIAction` prop. + */ + }, +}); + +/** + * Extended web component class that implements connectedMoveCallback. + * + * When an element is moved in the DOM using moveBefore(), browsers that support + * the "atomic move" feature (https://developer.chrome.com/blog/movebefore-api) + * will call connectedMoveCallback instead of disconnectedCallback/connectedCallback. + * + * By implementing an empty connectedMoveCallback, we signal that the component + * should preserve its internal state (including iframe content) when moved, + * rather than being fully torn down and recreated. + */ +class UIResourceRendererWC extends BaseUIResourceRendererWC { + /** + * Called when the element is moved via moveBefore() in browsers that support atomic moves. + * By implementing this method (even as a no-op), we prevent the element from being + * disconnected and reconnected, which would cause iframes to reload and lose state. + */ + connectedMoveCallback() { + // Intentionally empty - by implementing this callback, we opt into atomic move behavior + // and prevent the iframe from reloading when the element is repositioned in the DOM. + } +} + +customElements.define('ui-resource-renderer', UIResourceRendererWC); diff --git a/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx b/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx index 7fc883ce..c13dfbf7 100644 --- a/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx +++ b/sdks/typescript/client/src/components/__tests__/UIResourceRendererWC.test.tsx @@ -84,4 +84,68 @@ describe('UIResourceRendererWC', () => { const dispatchedEvent = onUIAction.mock.calls[0][0] as CustomEvent; expect(dispatchedEvent.detail).toEqual(mockEventPayload); }); + + describe('connectedMoveCallback (atomic move support)', () => { + it('should implement connectedMoveCallback method', () => { + const el = document.createElement('ui-resource-renderer'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(typeof (el as any).connectedMoveCallback).toBe('function'); + }); + + it('connectedMoveCallback should be callable without throwing', () => { + const el = document.createElement('ui-resource-renderer'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (el as any).connectedMoveCallback()).not.toThrow(); + }); + + it('should allow element to be moved between containers (simulating moveBefore)', async () => { + const el = document.createElement('ui-resource-renderer'); + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + + document.body.appendChild(container1); + document.body.appendChild(container2); + container1.appendChild(el); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (el as any).resource = resource; + + // Wait for the component to render + await waitFor(() => { + expect(UIResourceRenderer).toHaveBeenCalled(); + }); + + // Verify initial position + expect(el.parentElement).toBe(container1); + + // In browsers that support moveBefore with atomic moves: + // - moveBefore() is called + // - connectedMoveCallback is invoked instead of disconnectedCallback/connectedCallback + // - The element preserves its state (iframes don't reload) + // + // Here we verify that connectedMoveCallback doesn't throw and the element can be moved. + // Full atomic move behavior requires browser support that jsdom doesn't have. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (el as any).connectedMoveCallback()).not.toThrow(); + + // Move the element to a new container + container2.appendChild(el); + + // Verify element moved successfully + expect(el.parentElement).toBe(container2); + expect(container1.contains(el)).toBe(false); + expect(container2.contains(el)).toBe(true); + }); + + it('element should be an instance of the extended class with connectedMoveCallback', () => { + const el = document.createElement('ui-resource-renderer'); + + // Verify the element has the connectedMoveCallback method + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect('connectedMoveCallback' in el).toBe(true); + + // The element should be an HTMLElement + expect(el instanceof HTMLElement).toBe(true); + }); + }); });