Skip to content

Commit 37a66ee

Browse files
Add the ability to override element methods (#542)
1 parent 2eba7a2 commit 37a66ee

File tree

6 files changed

+209
-3
lines changed

6 files changed

+209
-3
lines changed

integration-test/test-pages/runtime-checks/config/basic-run.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"matchAllDomains": "enabled",
99
"matchAllStackDomains": "enabled",
1010
"overloadInstanceOf": "enabled",
11+
"overloadRemoveChild": "enabled",
12+
"overloadReplaceChild": "enabled",
1113
"elementRemovalTimeout": 100,
1214
"injectGlobalStyles": "enabled",
1315
"domains": [

integration-test/test-pages/runtime-checks/config/replace-element.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"matchAllDomains": "enabled",
99
"matchAllStackDomains": "enabled",
1010
"overloadInstanceOf": "enabled",
11+
"overloadRemoveChild": "enabled",
12+
"overloadReplaceChild": "enabled",
1113
"elementRemovalTimeout": 100,
1214
"injectGlobalStyles": "enabled",
1315
"replaceElement": "enabled",

integration-test/test-pages/runtime-checks/pages/basic-run.html

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,82 @@
4141
];
4242
});
4343

44+
test('Script that should be removable', async () => {
45+
let thrownBefore = false;
46+
let thrownAfter = false;
47+
window.scriptyRemovableRan = false;
48+
const scriptElement = document.createElement('script');
49+
scriptElement.innerText = 'window.scriptyRemovableRan = true';
50+
scriptElement.id = 'scriptyRemovable';
51+
document.body.appendChild(scriptElement);
52+
const hadInspectorNode = scriptElement === document.querySelector('ddg-runtime-checks:last-of-type');
53+
const instanceofResult = scriptElement instanceof HTMLScriptElement;
54+
const scripty = document.querySelector('script#scriptyRemovable');
55+
try {
56+
document.body.removeChild(scriptElement);
57+
} catch (e) {
58+
thrownBefore = true
59+
}
60+
const connectedAfterRemoval = scripty.isConnected
61+
await new Promise(resolve => setTimeout(resolve, 300));
62+
try {
63+
document.body.removeChild(scriptElement);
64+
} catch (e) {
65+
thrownAfter = true
66+
}
67+
68+
return [
69+
{ name: 'hadInspectorNode', result: hadInspectorNode, expected: true },
70+
{ name: 'expect script to match', result: scripty, expected: scriptElement },
71+
{ name: 'instanceof matches HTMLScriptElement', result: instanceofResult, expected: true },
72+
{ name: 'scripty.id', result: scripty.id, expected: 'scriptyRemovable' },
73+
{ name: 'script ran', result: window.scriptyRemovableRan, expected: true },
74+
{ name: 'script is connected after removal', result: connectedAfterRemoval, expected: false },
75+
{ name: 'script removal before expiry throws', result: thrownBefore, expected: false },
76+
{ name: 'script removal after expiry throws', result: thrownAfter, expected: true }
77+
];
78+
});
79+
80+
test('Script that should be replaceable', async () => {
81+
let thrownBefore = false;
82+
let thrownAfter = false;
83+
window.scriptyReplaceable = false;
84+
const scriptElement = document.createElement('script');
85+
scriptElement.innerText = 'window.scriptyReplaceableRan = true';
86+
scriptElement.id = 'scriptyReplaceable';
87+
document.body.appendChild(scriptElement);
88+
const hadInspectorNode = scriptElement === document.querySelector('ddg-runtime-checks:last-of-type');
89+
const instanceofResult = scriptElement instanceof HTMLScriptElement;
90+
const scripty = document.querySelector('script#scriptyReplaceable');
91+
const someDivElement = document.createElement('div');
92+
someDivElement.id = "thing"
93+
try {
94+
document.body.replaceChild(someDivElement, scriptElement);
95+
} catch (e) {
96+
thrownBefore = true
97+
}
98+
const connectedAfterRemoval = scripty.isConnected
99+
await new Promise(resolve => setTimeout(resolve, 300));
100+
try {
101+
document.body.replaceChild(someDivElement, scriptElement);
102+
} catch (e) {
103+
thrownAfter = true
104+
}
105+
const divNode = document.querySelector('#thing');
106+
107+
return [
108+
{ name: 'hadInspectorNode', result: hadInspectorNode, expected: true },
109+
{ name: 'expect script to match', result: scripty, expected: scriptElement },
110+
{ name: 'instanceof matches HTMLScriptElement', result: instanceofResult, expected: true },
111+
{ name: 'scripty.id', result: scripty.id, expected: 'scriptyReplaceable' },
112+
{ name: 'script ran', result: window.scriptyReplaceableRan, expected: true },
113+
{ name: 'div node present', result: !!divNode, expected: true },
114+
{ name: 'script is connected after removal', result: connectedAfterRemoval, expected: false },
115+
{ name: 'script removal before expiry throws', result: thrownBefore, expected: false },
116+
{ name: 'script removal after expiry throws', result: thrownAfter, expected: true }
117+
];
118+
});
119+
44120
test('Script removal', async () => {
45121
window.scripty1Ran = false;
46122
const scriptElement = document.createElement('script');

integration-test/test-pages/runtime-checks/pages/replace-element.html

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,85 @@
5959
];
6060
});
6161

62+
test('Replace: Script that should be removable', async () => {
63+
let thrownBefore = false;
64+
let thrownAfter = false;
65+
window.scriptyRemovableRan = false;
66+
const scriptElement = document.createElement('script');
67+
scriptElement.innerText = 'window.scriptyRemovableRan = true';
68+
scriptElement.id = 'scriptyRemovable';
69+
document.body.appendChild(scriptElement);
70+
const runtimeWasConnected = scriptElement.isConnected;
71+
const instanceofResult = scriptElement instanceof HTMLScriptElement;
72+
const scripty = document.querySelector('script#scriptyRemovable');
73+
const connectedBeforeRemoval = scripty.isConnected
74+
try {
75+
document.body.removeChild(scriptElement);
76+
} catch (e) {
77+
thrownBefore = true
78+
}
79+
const connectedAfterRemoval = scripty.isConnected
80+
const runtimeConnectedAfterRemoval = scriptElement.isConnected;
81+
try {
82+
document.body.removeChild(scriptElement);
83+
} catch (e) {
84+
thrownAfter = true
85+
}
86+
// Ensure real node is gone
87+
const scripty2 = document.querySelector('script#scriptyRemovable');
88+
return [
89+
{ name: 'expect script to match', result: scripty, expected: scriptElement },
90+
{ name: 'instanceof matches HTMLScriptElement', result: instanceofResult, expected: true },
91+
{ name: 'was connected after append', result: runtimeWasConnected, expected: true },
92+
{ name: 'real script is connected after append', result: connectedBeforeRemoval, expected: true },
93+
{ name: 'scripty.id', result: scripty.id, expected: 'scriptyRemovable' },
94+
{ name: 'script ran', result: window.scriptyRemovableRan, expected: true },
95+
{ name: 'script is connected after removal', result: runtimeConnectedAfterRemoval, expected: false },
96+
{ name: 'real script is connected after removal', result: connectedAfterRemoval, expected: false },
97+
{ name: 'original node is connected after removal', result: scriptElement.isConnected, expected: false },
98+
{ name: 'script removal 1st throws', result: thrownBefore, expected: false },
99+
{ name: 'script removal 2nd throws', result: thrownAfter, expected: true },
100+
{ name: 'scripty2', result: scripty2, expected: null }
101+
];
102+
});
103+
104+
test('Replace: Script that should be replaceable', async () => {
105+
let thrownBefore = false;
106+
let thrownAfter = false;
107+
window.scriptyReplaceable = false;
108+
const scriptElement = document.createElement('script');
109+
scriptElement.innerText = 'window.scriptyReplaceableRan = true';
110+
scriptElement.id = 'scriptyReplaceable';
111+
document.body.appendChild(scriptElement);
112+
const instanceofResult = scriptElement instanceof HTMLScriptElement;
113+
const scripty = document.querySelector('script#scriptyReplaceable');
114+
const someDivElement = document.createElement('div');
115+
someDivElement.id = "thing"
116+
try {
117+
document.body.replaceChild(someDivElement, scriptElement);
118+
} catch (e) {
119+
thrownBefore = true
120+
}
121+
const connectedAfterRemoval = scripty.isConnected
122+
try {
123+
document.body.replaceChild(someDivElement, scriptElement);
124+
} catch (e) {
125+
thrownAfter = true
126+
}
127+
const divNode = document.querySelector('#thing');
128+
129+
return [
130+
{ name: 'expect script to match', result: scripty, expected: scriptElement },
131+
{ name: 'instanceof matches HTMLScriptElement', result: instanceofResult, expected: true },
132+
{ name: 'scripty.id', result: scripty.id, expected: 'scriptyReplaceable' },
133+
{ name: 'script ran', result: window.scriptyReplaceableRan, expected: true },
134+
{ name: 'div node present', result: !!divNode, expected: true },
135+
{ name: 'script is connected after removal', result: connectedAfterRemoval, expected: false },
136+
{ name: 'script removal 1st throws', result: thrownBefore, expected: false },
137+
{ name: 'script removal 2nd throws', result: thrownAfter, expected: true }
138+
];
139+
});
140+
62141
test('Replace: Script that should execute', async () => {
63142
window.scripty2Ran = false;
64143
const scriptElement = document.createElement('script');

src/features/runtime-checks.js

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,17 +221,18 @@ class DDGRuntimeChecks extends HTMLElement {
221221
this.computeScriptOverload(el)
222222
}
223223

224-
// TODO pollyfill WeakRef
225-
this.#el = new WeakRef(el)
226-
227224
if (replaceElement) {
228225
this.replaceElement(el)
229226
} else {
230227
this.insertAfterAndRemove(el)
231228
}
229+
230+
// TODO pollyfill WeakRef
231+
this.#el = new WeakRef(el)
232232
}
233233

234234
replaceElement (el) {
235+
// This should be called before this.#el is set
235236
// @ts-expect-error - this is wrong node type
236237
super.parentElement?.replaceChild(el, this)
237238

@@ -487,6 +488,39 @@ function injectGenericOverloads () {
487488
*/
488489
}
489490

491+
function overloadRemoveChild () {
492+
const proxy = new DDGProxy(featureName, Node.prototype, 'removeChild', {
493+
apply (fn, scope, args) {
494+
const child = args[0]
495+
if (child instanceof DDGRuntimeChecks) {
496+
// Should call the real removeChild method if it's already replaced
497+
const realNode = child._getElement()
498+
if (realNode) {
499+
args[0] = realNode
500+
}
501+
}
502+
return Reflect.apply(fn, scope, args)
503+
}
504+
})
505+
proxy.overloadDescriptor()
506+
}
507+
508+
function overloadReplaceChild () {
509+
const proxy = new DDGProxy(featureName, Node.prototype, 'replaceChild', {
510+
apply (fn, scope, args) {
511+
const newChild = args[1]
512+
if (newChild instanceof DDGRuntimeChecks) {
513+
const realNode = newChild._getElement()
514+
if (realNode) {
515+
args[1] = realNode
516+
}
517+
}
518+
return Reflect.apply(fn, scope, args)
519+
}
520+
})
521+
proxy.overloadDescriptor()
522+
}
523+
490524
export default class RuntimeChecks extends ContentFeature {
491525
load () {
492526
// This shouldn't happen, but if it does we don't want to break the page
@@ -532,5 +566,11 @@ export default class RuntimeChecks extends ContentFeature {
532566
if (injectGenericOverloadsEnabled) {
533567
injectGenericOverloads()
534568
}
569+
if (this.getFeatureSettingEnabled('overloadRemoveChild')) {
570+
overloadRemoveChild()
571+
}
572+
if (this.getFeatureSettingEnabled('overloadReplaceChild')) {
573+
overloadReplaceChild()
574+
}
535575
}
536576
}

src/utils.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* global cloneInto, exportFunction, mozProxies */
22
import { Set } from './captured-globals.js'
3+
import { defineProperty } from './wrapper-utils.js'
34

45
// Only use globalThis for testing this breaks window.wrappedJSObject code in Firefox
56
// eslint-disable-next-line no-global-assign
@@ -444,6 +445,12 @@ export class DDGProxy {
444445
this.objectScope[this.property] = this.internal
445446
}
446447
}
448+
449+
overloadDescriptor () {
450+
defineProperty(this.objectScope, this.property, {
451+
value: this.internal
452+
})
453+
}
447454
}
448455

449456
export function postDebugMessage (feature, message) {

0 commit comments

Comments
 (0)