Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 37 additions & 14 deletions sdks/typescript/client/src/components/UIResourceRendererWC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,40 @@ export const UIResourceRendererWCWrapper: FC<UIResourceRendererWCProps> = (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);
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading