Commit 520dc242f1 for handsontable.com
commit 520dc242f164c1ac3e59b542126e37622c51259f
Author: Artur Mędrygał <artur.medrygal@handsontable.com>
Date: Mon Sep 14 12:33:16 2026 +0200
DEV-2795: Fix copy and paste in a Salesforce Lightning Web Component (#13389)
(cherry picked from commit fbf62b072d32f8a984c901a2a7ff73fffb26c15f)
diff --git a/.changelogs/13388.json b/.changelogs/13388.json
new file mode 100644
index 0000000000..9096543f59
--- /dev/null
+++ b/.changelogs/13388.json
@@ -0,0 +1,8 @@
+{
+ "issuesOrigin": "public",
+ "title": "Fixed copying, cutting, and pasting doing nothing in a grid embedded in a Salesforce Lightning Web Component.",
+ "type": "fixed",
+ "issueOrPR": 13388,
+ "breaking": false,
+ "framework": "none"
+}
diff --git a/docs/content/guides/tools-and-building/shadow-dom/shadow-dom.md b/docs/content/guides/tools-and-building/shadow-dom/shadow-dom.md
index c00fa12f1e..c8dbdfde51 100644
--- a/docs/content/guides/tools-and-building/shadow-dom/shadow-dom.md
+++ b/docs/content/guides/tools-and-building/shadow-dom/shadow-dom.md
@@ -90,6 +90,8 @@ The class also applies `isolation: isolate` to the wrapper. Without it, the grid
Some platforms wrap the DOM APIs in a security sandbox. Salesforce Lightning Web Security (LWS) filters `Event#composedPath()` and limits `document.activeElement` resolution for the sandboxed code. Handsontable falls back to signals that stay reliable in such environments: per-listener event targets and focus tracking within its own DOM tree. Cell editing, selection, keyboard handling, and copy and paste work under LWS without wrapper-side workarounds.
+A sandbox can also narrow how far a clipboard event travels before your code sees it. Handsontable binds its `copy`, `cut`, and `paste` listeners on the grid's own element, on the document, and on the grid's shadow root, so the shortcuts work whichever of those the host delivers to. You don't need to forward clipboard events to the grid yourself.
+
Salesforce Lightning Experience renders Lightning Web Components with its synthetic Shadow DOM polyfill by default. Handsontable works in that default mode. It also works with the native opt-in (`static shadowSupportMode = 'native'`), with one requirement: Salesforce's `loadStyle` injects CSS into the document head, which a native shadow root ignores. Inject both Handsontable stylesheets into the component's shadow tree instead - for example, append the `<link>` elements inside the `lwc:dom="manual"` container and wait for their `load` events before you create the grid.
## Known limitations
diff --git a/handsontable/src/plugins/copyPaste/copyPaste.ts b/handsontable/src/plugins/copyPaste/copyPaste.ts
index e5f7ca2840..c1d905ba54 100644
--- a/handsontable/src/plugins/copyPaste/copyPaste.ts
+++ b/handsontable/src/plugins/copyPaste/copyPaste.ts
@@ -191,9 +191,11 @@ export class CopyPaste extends BasePlugin {
*/
#copyMode = 'cells-only';
/**
- * Registry of the clipboard events that were already processed. When the grid lives inside
- * a Shadow DOM tree, the clipboard listeners are bound both to the document and to the
- * grid's shadow root, so the same event instance can reach the plugin twice.
+ * Registry of the clipboard events that were already processed. The clipboard listeners are
+ * bound to the document, to the grid's own element, and - when the grid lives inside a Shadow
+ * DOM tree - to its shadow root, so the same event instance reaches the plugin more than once
+ * for every grid, not just a grid inside a shadow tree. Keyed on event identity, which holds
+ * because `EventManager` hands every listener the same event object.
*
* @type {WeakSet<object>}
*/
@@ -271,8 +273,11 @@ export class CopyPaste extends BasePlugin {
this.addHook('afterSelection', this.#onAfterSelection);
this.addHook('afterSelectionEnd', this.#onAfterSelectionEnd);
- // Events are attached to the document, not the root table element - as it should,
- // for Chrome 133 and lower to copy/paste/cut work properly (#dev-2277).
+ // The same three listeners go on up to three targets, because a clipboard event reaches
+ // the grid by a different route depending on where the grid is embedded. More than one
+ // of them can see the same event, so `#processedClipboardEvents` keeps the handler
+ // running once - the event manager hands every listener the same event object, which is
+ // what makes identity a usable key here.
const dedupe = (handler: (event: ClipboardEvent) => void) => (event: ClipboardEvent) => {
if (this.#processedClipboardEvents.has(event)) {
return;
@@ -280,23 +285,31 @@ export class CopyPaste extends BasePlugin {
this.#processedClipboardEvents.add(event);
handler(event);
};
+ const bindClipboardListeners = (target: Element | Document | ShadowRoot) => {
+ this.eventManager.addEventListener(target, 'copy', dedupe((e: ClipboardEvent) => this.onCopy(e)));
+ this.eventManager.addEventListener(target, 'cut', dedupe((e: ClipboardEvent) => this.onCut(e)));
+ this.eventManager.addEventListener(target, 'paste', dedupe((e: ClipboardEvent) => this.onPaste(e)));
+ };
+
+ // The document. It carries the events that target `document.body`, which sits outside the
+ // grid where no listener below it would ever see them, and it is what keeps copy/cut/paste
+ // working on Chrome 133 and lower (#dev-2277). Never drop it.
+ bindClipboardListeners(this.hot.rootDocument);
- this.eventManager.addEventListener(this.hot.rootDocument, 'copy', dedupe((e: ClipboardEvent) => this.onCopy(e)));
- this.eventManager.addEventListener(this.hot.rootDocument, 'cut', dedupe((e: ClipboardEvent) => this.onCut(e)));
- this.eventManager.addEventListener(this.hot.rootDocument, 'paste', dedupe((e: ClipboardEvent) => this.onPaste(e)));
+ // The grid's own element. Sandboxed hosts (Salesforce Lightning Web Security) deliver
+ // clipboard events only to listeners bound at or below the element the grid owns - neither
+ // the document nor the shadow-root listeners below run there, which left copy, cut and
+ // paste doing nothing inside a Lightning Web Component (#dev-2795). Everywhere else this
+ // listener just sees an in-grid event before the others do, and they dedupe against it.
+ bindClipboardListeners(this.hot.rootElement);
const rootNode = this.hot.rootElement.getRootNode();
- // When the grid lives inside a Shadow DOM tree, the same listeners are attached to the
- // grid's shadow root as well. Sandboxed hosts (e.g. Salesforce Lightning Web Security)
- // retarget events observed at the document level, which hides the grid internals from
- // the document listeners above. Listeners bound inside the grid's own shadow tree still
- // receive the untouched event path. The `#processedClipboardEvents` registry prevents
- // double handling when both listeners receive the same event.
+ // The grid's shadow root, when it has one. Sandboxed hosts retarget events observed at the
+ // document level, which hides the grid internals from the document listeners; listeners
+ // bound inside the grid's own shadow tree still receive the untouched event path.
if (isShadowRoot(rootNode)) {
- this.eventManager.addEventListener(rootNode, 'copy', dedupe((e: ClipboardEvent) => this.onCopy(e)));
- this.eventManager.addEventListener(rootNode, 'cut', dedupe((e: ClipboardEvent) => this.onCut(e)));
- this.eventManager.addEventListener(rootNode, 'paste', dedupe((e: ClipboardEvent) => this.onPaste(e)));
+ bindClipboardListeners(rootNode);
}
// Without this workaround Safari (tested on Safari@16.5.2) does allow copying/cutting from the browser menu.
diff --git a/tests/e2e/shadow-dom.spec.ts b/tests/e2e/shadow-dom.spec.ts
index f96420ac23..af233f06df 100644
--- a/tests/e2e/shadow-dom.spec.ts
+++ b/tests/e2e/shadow-dom.spec.ts
@@ -57,6 +57,12 @@ test.describe('grid inside a native shadow root', () => {
await grid.page.keyboard.press('ControlOrMeta+v');
await grid.expectCell(4, 2, 'A1');
+ // Under the default delivery mode the events do travel out to the document. This is the
+ // positive control for the recorder the `lws-shape` tests below assert stays empty.
+ expect(await grid.clipboardEventsSeenAtDocument()).toContain('paste');
+ // This is also the mode where all three listeners see the event, so it is where a broken
+ // event registry would show up as the plugin pasting more than once.
+ expect(await grid.pasteHookCalls()).toBe(1);
});
test('does not steal focus back when typing into an input outside the shadow host', async () => {
@@ -111,3 +117,54 @@ test.describe('grid inside a native shadow root', () => {
await expect.poll(() => grid.selected()).toBeNull();
});
});
+
+/**
+ * Clipboard events shaped the way Salesforce Lightning Web Security shapes them (DEV-2795,
+ * #13388). LWS hands them only to listeners bound at or below the grid's own element, so the
+ * two binding points CopyPaste had — the document and the grid's shadow root — both went
+ * unused and copy, cut and paste silently did nothing in a Lightning Web Component. It also
+ * collapses `composedPath()` to the shadow host chain, which is what makes the plugin resolve
+ * the event's source from the retargeted `target` instead. The fixture reproduces both.
+ *
+ * These tests cover that shape, not LWS itself: a real org also runs the grid behind a sandbox
+ * membrane, which no fixture here can stand in for.
+ */
+test.describe('grid whose clipboard events arrive the way LWS delivers them', () => {
+ let grid: ShadowGridPage;
+
+ test.beforeEach(async ({ page, theme, bundle }) => {
+ grid = new ShadowGridPage(page, theme, bundle, 'lws-shape');
+ await grid.goto();
+ });
+
+ // C3 is copied by no other test in this file. The browser clipboard outlives a test — each
+ // test gets a fresh context, not a fresh clipboard — so reusing a value an earlier test
+ // copied would let a broken copy still paste that leftover and pass.
+ test('copies and pastes between cells with keyboard shortcuts', async () => {
+ await grid.cell(2, 2).click();
+ await grid.page.keyboard.press('ControlOrMeta+c');
+
+ await grid.cell(4, 0).click();
+ await grid.page.keyboard.press('ControlOrMeta+v');
+
+ await grid.expectCell(4, 0, 'C3');
+ // The paste landed without the document ever seeing the event, so only a listener bound
+ // at or below the container can have driven it.
+ expect(await grid.clipboardEventsSeenAtDocument()).toEqual([]);
+ expect(await grid.pasteHookCalls()).toBe(1);
+ });
+
+ test('cuts and pastes between cells with keyboard shortcuts', async () => {
+ await grid.cell(0, 1).click();
+ await grid.page.keyboard.press('ControlOrMeta+x');
+
+ await grid.expectCell(0, 1, '');
+
+ await grid.cell(3, 0).click();
+ await grid.page.keyboard.press('ControlOrMeta+v');
+
+ await grid.expectCell(3, 0, 'B1');
+ expect(await grid.clipboardEventsSeenAtDocument()).toEqual([]);
+ expect(await grid.pasteHookCalls()).toBe(1);
+ });
+});
diff --git a/tests/fixtures/demo/grid.html b/tests/fixtures/demo/grid.html
index f364c3133a..92af3edf48 100644
--- a/tests/fixtures/demo/grid.html
+++ b/tests/fixtures/demo/grid.html
@@ -73,6 +73,16 @@
document.querySelector('[data-testid="add-row"]').addEventListener('click', () => {
hot.alter('insert_row_below', hot.countRows() - 1);
});
+
+ // How many times the plugin ran a paste to completion. CopyPaste binds its clipboard
+ // listeners on the document AND on the grid's own element (DEV-2795), so one Ctrl+V reaches
+ // the plugin twice even on a plain page, and only the event registry keeps the handler
+ // running once. Under the default `overwrite` paste mode a double paste writes the same
+ // values twice and looks identical, so counting the hook is the only way to see it.
+ window.__pasteHookCalls = 0;
+ hot.addHook('afterPaste', () => {
+ window.__pasteHookCalls += 1;
+ });
</script>
</body>
</html>
diff --git a/tests/fixtures/demo/shadow-dom.html b/tests/fixtures/demo/shadow-dom.html
index 0514b19ce3..10209e3bd6 100644
--- a/tests/fixtures/demo/shadow-dom.html
+++ b/tests/fixtures/demo/shadow-dom.html
@@ -21,6 +21,14 @@
if (!window.htBundle) {
throw new Error('Unknown ?bundle= value: ' + JSON.stringify(htParams.get('bundle')));
}
+
+ // How a clipboard event reaches the grid. `lws-shape` stands in for the two things
+ // Salesforce Lightning Web Security does that matter here (DEV-2795) — see the block that
+ // installs it below. Same fail-loud allowlist as the two params above.
+ window.htDelivery = ({ normal: 'normal', 'lws-shape': 'lws-shape' })[htParams.get('delivery') ?? 'normal'];
+ if (!window.htDelivery) {
+ throw new Error('Unknown ?delivery= value: ' + JSON.stringify(htParams.get('delivery')));
+ }
</script>
<style> body { font-family: sans-serif; margin: 1rem; } #outside-area { margin-top: .8rem; } </style>
</head>
@@ -64,6 +72,51 @@
container.setAttribute('data-testid', 'grid');
shadowRoot.appendChild(container);
+ // Every clipboard event that makes it out to the document, recorded. In the default
+ // delivery mode this fills up; under `container-only` it must stay empty, which is what
+ // proves the grid was driven by a listener the event reached before the document.
+ window.__clipboardAtDocument = [];
+ ['copy', 'cut', 'paste'].forEach((eventName) => {
+ document.addEventListener(eventName, () => window.__clipboardAtDocument.push(eventName));
+ });
+
+ // Salesforce Lightning Web Security (DEV-2795, #13388) changes two things about a clipboard
+ // event, and the grid needs both to be true at once before it looks like a real org.
+ //
+ // 1. Delivery. LWS hands the event only to listeners bound at or below the grid's own
+ // container — the ones CopyPaste binds on the shadow root and on the document never run.
+ // `stopPropagation` on the container has exactly that reach: every listener ON the
+ // container still fires (only `stopImmediatePropagation` would cut those off, whatever
+ // the registration order), while the shadow root and the document above it stop seeing
+ // the event. Bound in the bubble phase, and installed before the grid exists, so it
+ // cannot depend on the plugin's own listener order.
+ //
+ // 2. `composedPath()`. LWS collapses it to the shadow host chain, so the grid internals are
+ // gone from it. That is what sends `#resolveClipboardEventTarget` down its second branch,
+ // onto the retargeted `event.target` — the branch that actually carries the paste in an
+ // org. Without this the path stays intact, the first branch answers, and the LWS half of
+ // that method is never exercised. Applied in the CAPTURE phase on the container so it is
+ // in place before any bubble listener reads the path.
+ //
+ // Together these reproduce the SHAPE of LWS, not LWS: a real org also runs the grid behind a
+ // membrane that proxies the DOM itself. Only an org can confirm the whole picture.
+ if (window.htDelivery === 'lws-shape') {
+ const hostChain = [
+ document.querySelector('[data-testid="shadow-host"]'),
+ document.body,
+ document.documentElement,
+ document,
+ window,
+ ];
+
+ ['copy', 'cut', 'paste'].forEach((eventName) => {
+ container.addEventListener(eventName, (event) => {
+ event.composedPath = () => hostChain;
+ }, true);
+ container.addEventListener(eventName, (event) => event.stopPropagation());
+ });
+ }
+
const shadowSibling = document.createElement('div');
shadowSibling.setAttribute('data-testid', 'shadow-sibling');
shadowSibling.style.cssText = 'padding: 16px; margin-top: 8px; background: #eee;';
@@ -91,6 +144,16 @@
licenseKey: 'non-commercial-and-evaluation',
});
+ // How many times the plugin ran a paste to completion. Every grid now has more than one
+ // clipboard listener bound, so a single Ctrl+V reaches the plugin several times and only
+ // the event registry keeps the handler running once. Counting the hook is what would catch
+ // that registry failing: under the default `overwrite` paste mode, writing the same values
+ // twice looks identical to writing them once, so no value assertion can see it.
+ window.__pasteHookCalls = 0;
+ hot.addHook('afterPaste', () => {
+ window.__pasteHookCalls += 1;
+ });
+
document.querySelector('[data-testid="focus-mover"]').addEventListener('mousedown', (event) => {
event.preventDefault();
document.querySelector('[data-testid="outside-input"]').focus();
@@ -114,6 +177,8 @@
window.__outsideClickTargets = targets;
},
outsideClickTargets: () => window.__outsideClickTargets,
+ clipboardEventsSeenAtDocument: () => window.__clipboardAtDocument,
+ pasteHookCalls: () => window.__pasteHookCalls,
};
</script>
</body>
diff --git a/tests/fixtures/pages/GridPage.ts b/tests/fixtures/pages/GridPage.ts
index ce83881b16..0b267bedc2 100644
--- a/tests/fixtures/pages/GridPage.ts
+++ b/tests/fixtures/pages/GridPage.ts
@@ -101,4 +101,12 @@ export class GridPage {
async clipboardText(): Promise<string> {
return this.page.evaluate(() => navigator.clipboard.readText());
}
+
+ /**
+ * How many times the plugin ran a paste to completion, counted through `afterPaste` (fixture
+ * probe). One Ctrl+V must produce exactly one, however many listeners saw the event.
+ */
+ async pasteHookCalls(): Promise<number> {
+ return this.page.evaluate(() => (window as any).__pasteHookCalls);
+ }
}
diff --git a/tests/fixtures/pages/ShadowGridPage.ts b/tests/fixtures/pages/ShadowGridPage.ts
index 5f89f9edcc..77e70af0d1 100644
--- a/tests/fixtures/pages/ShadowGridPage.ts
+++ b/tests/fixtures/pages/ShadowGridPage.ts
@@ -12,16 +12,19 @@ export class ShadowGridPage {
readonly page: Page;
readonly theme: string;
readonly bundle: string;
+ /** Clipboard-event delivery mode: `'normal'`, or `'lws-shape'` to stand in for LWS. */
+ readonly delivery: string;
readonly grid: Locator;
readonly outsideTextarea: Locator;
readonly outsideInput: Locator;
readonly shadowSibling: Locator;
readonly focusMover: Locator;
- constructor(page: Page, theme = 'main', bundle = 'umd') {
+ constructor(page: Page, theme = 'main', bundle = 'umd', delivery = 'normal') {
this.page = page;
this.theme = theme;
this.bundle = bundle;
+ this.delivery = delivery;
this.grid = page.getByTestId('grid');
this.outsideTextarea = page.getByTestId('outside-textarea');
this.outsideInput = page.getByTestId('outside-input');
@@ -35,10 +38,28 @@ export class ShadowGridPage {
* readiness flags.
*/
async goto(): Promise<void> {
- await this.page.goto(`/tests/fixtures/demo/shadow-dom.html?theme=${this.theme}&bundle=${this.bundle}`);
+ await this.page.goto(
+ `/tests/fixtures/demo/shadow-dom.html?theme=${this.theme}&bundle=${this.bundle}&delivery=${this.delivery}`
+ );
await expect(this.cell(0, 0)).toBeVisible();
}
+ /**
+ * Names of the clipboard events that reached the document (fixture probe). Empty under the
+ * `lws-shape` delivery mode, where the fixture stops them at the grid's container.
+ */
+ async clipboardEventsSeenAtDocument(): Promise<string[]> {
+ return this.page.evaluate(() => (window as any).__hotProbe.clipboardEventsSeenAtDocument());
+ }
+
+ /**
+ * How many times the plugin ran a paste to completion, counted through `afterPaste` (fixture
+ * probe). One Ctrl+V must produce exactly one, however many listeners saw the event.
+ */
+ async pasteHookCalls(): Promise<number> {
+ return this.page.evaluate(() => (window as any).__hotProbe.pasteHookCalls());
+ }
+
/** A single data cell, by visual row/column, via its stable test id. */
cell(row: number, col: number): Locator {
return this.page.getByTestId(`cell-${row}-${col}`);