Repository: sveltejs/svelte (JavaScript)
Date: 2019-06-09 21:58:58+00:00
Task type: Bug Fix

Issue:
bind:clientWidth doesn't work in sandbox
[REPL](https://v3.svelte.technology/repl?version=3.0.0-beta.9&gist=648026c694e2323428e82b60c38829d0). Resizing the viewer window doesn't change anything.


Issue discussion:
I believe it's repl only issue. Seems like `contentDocument` of the created object element (which used for resize handler) is null if used with `eval`. The same issue exists in v2 ([REPL](https://svelte.technology/repl?version=2.16.1&gist=5c1bec42b3ef51d7574985cc1926d4c5)).

update: I've tested the example in a Sapper app and all work as expected.
Yeah, this is tricky. There's no way to use the element resize listener technique this binding uses inside an iframe that doesn't have both `allow-scripts` and `allow-same-origin` sandbox properties, which is strongly discouraged (since it basically allows the sandbox to be completely bypassed).

Options:

* Relax sandbox restrictions and hope that no-one creates an app that maliciously uses other people's GitHub credentials to modify gists
* Have broken tutorials
* Change the behaviour of the binding so that it polls for changes
* Use ResizeObserver instead (t only works on Chrome)
* Serve the REPL on a different domain and come up with some convoluted message-passing mechanism (I think this is what CodeSandbox is doing? [This v2 example](https://codesandbox.io/s/zlvvn7vn2p) works)
* Add some UI that allows the user to opt in to a more relaxed sandbox, if they trust the app

All these options are terrible. The last one is probably the least terrible. Am I missing any?
Below works on firefox, but is a bit heavy.

```javascript
let resizeListenerId = 0;
function addResizeListener(element, fn) {
    if (getComputedStyle(element).position === 'static') {
        element.style.position = 'relative';
    }

    const object = document.createElement('object');
    object.setAttribute('style', 'display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; pointer-events: none; z-index: -1;');
    object.type = 'text/html';

    let win;
    let resizeMessageKey = `resized-${resizeListenerId++}`
    let eventProxyScript = "data:text/html;charset=utf-8,"+ encodeURIComponent(
        `<script>window.addEventListener('resize',  () => window.parent.postMessage("${resizeMessageKey}",'*'))</script>`
    );
    let messageHandler
    
    object.onload = () => {
        if (window.origin == 'null') {
            messageHandler = (e) => { if (e.data == resizeMessageKey) fn() }
            window.addEventListener('message', messageHandler );
        } else {
            win = object.contentDocument.defaultView;
            win.addEventListener('resize', fn);
        }
        
    };

    if (/Trident/.test(navigator.userAgent)) {
        element.appendChild(object);
        object.data = window.origin == 'null' ? eventProxyScript : 'about:blank';
    } else {
        object.data = window.origin == 'null' ? eventProxyScript : 'about:blank';
        element.appendChild(object);
    }

    return {
        cancel: () => {
            if (win && win.removeEventListener) {
                win.removeEventListener('resize', fn);
            } 
            if (messageHandler) {
                window.removeEventListener('message', messageHandler);
            }
            element.removeChild(object);
        }
    };
}
```
Set an alert of some kind that asks the user if they want to refresh?
How practical would it be to have the sandbox default to being more relaxed just on tutorials, since the only code getting run there would be stuff we write or that the user writes or pastes in. We could still keep the more restrictive settings on the main REPL, possibly with a button to relax them. Or we could even have the REPL default to relaxed if you visit it via one of the built-in examples, and only put it in the more restrictive mode upon loading a Gist.
Actually you know what? That's a great idea. Certainly in the short term — I guess it'd be a little confusing if you forked one of the examples and it stopped working.

We could also relax the sandbox if you're on your own gist, maybe. And perhaps we could detect the existence of non-sandbox-friendly things (if you're on someone else's gist) and display a warning in those cases?
This now works with @Conduitry's fix: https://v3.svelte.technology/tutorial/dimensions

Will leave this open so we can explore the more complete solutions later
> I guess it'd be a little confusing
mhmh, it was ... and and it took a lot time ;(

can you please write with big red letters, that it works, but just does not work in svelte.dev/repl




Solution patch:
diff --git a/src/compiler/compile/render_dom/wrappers/Element/index.ts b/src/compiler/compile/render_dom/wrappers/Element/index.ts
--- a/src/compiler/compile/render_dom/wrappers/Element/index.ts
+++ b/src/compiler/compile/render_dom/wrappers/Element/index.ts
@@ -586,7 +586,7 @@ export default class ElementWrapper extends Wrapper {
 					);
 
 					block.chunks.destroy.push(
-						b`${resize_listener}.cancel();`
+						b`${resize_listener}();`
 					);
 				} else {
 					block.event_listeners.push(
diff --git a/src/runtime/internal/dom.ts b/src/runtime/internal/dom.ts
--- a/src/runtime/internal/dom.ts
+++ b/src/runtime/internal/dom.ts
@@ -57,7 +57,7 @@ export function empty() {
 	return text('');
 }
 
-export function listen(node: Node, event: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | EventListenerOptions) {
+export function listen(node: EventTarget, event: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | EventListenerOptions) {
 	node.addEventListener(event, handler, options);
 	return () => node.removeEventListener(event, handler, options);
 }
@@ -234,37 +234,61 @@ export function select_multiple_value(select) {
 	return [].map.call(select.querySelectorAll(':checked'), option => option.__value);
 }
 
-export function add_resize_listener(element, fn) {
-	if (getComputedStyle(element).position === 'static') {
-		element.style.position = 'relative';
+// unfortunately this can't be a constant as that wouldn't be tree-shakeable
+// so we cache the result instead
+let crossorigin: boolean;
+
+export function is_crossorigin() {
+	if (crossorigin === undefined) {
+		crossorigin = false;
+
+		try {
+			if (typeof window !== 'undefined' && window.parent) {
+				void window.parent.document;
+			}
+		} catch (error) {
+			crossorigin = true;
+		}
 	}
 
-	const object = document.createElement('object');
-	object.setAttribute('style', 'display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; pointer-events: none; z-index: -1;');
-	object.setAttribute('aria-hidden', 'true');
-	object.type = 'text/html';
-	object.tabIndex = -1;
+	return crossorigin;
+}
 
-	let win;
+export function add_resize_listener(node: HTMLElement, fn: () => void) {
+	const computed_style = getComputedStyle(node);
+	const z_index = (parseInt(computed_style.zIndex) || 0) - 1;
 
-	object.onload = () => {
-		win = object.contentDocument.defaultView;
-		win.addEventListener('resize', fn);
-	};
+	if (computed_style.position === 'static') {
+		node.style.position = 'relative';
+	}
 
-	if (/Trident/.test(navigator.userAgent)) {
-		element.appendChild(object);
-		object.data = 'about:blank';
+	const iframe = element('iframe');
+	iframe.setAttribute('style',
+		`display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; ` +
+		`overflow: hidden; border: 0; opacity: 0; pointer-events: none; z-index: ${z_index};`
+	);
+	iframe.setAttribute('aria-hidden', 'true');
+	iframe.tabIndex = -1;
+
+	let unsubscribe: () => void;
+
+	if (is_crossorigin()) {
+		iframe.src = `data:text/html,<script>onresize=function(){parent.postMessage(0,'*')}</script>`;
+		unsubscribe = listen(window, 'message', (event: MessageEvent) => {
+			if (event.source === iframe.contentWindow) fn();
+		});
 	} else {
-		object.data = 'about:blank';
-		element.appendChild(object);
+		iframe.src = 'about:blank';
+		iframe.onload = () => {
+			unsubscribe = listen(iframe.contentWindow, 'resize', fn);
+		};
 	}
 
-	return {
-		cancel: () => {
-			win && win.removeEventListener && win.removeEventListener('resize', fn);
-			element.removeChild(object);
-		}
+	append(node, iframe);
+
+	return () => {
+		detach(iframe);
+		if (unsubscribe) unsubscribe();
 	};
 }
 
@@ -316,4 +340,4 @@ export class HtmlTag {
 	d() {
 		this.n.forEach(detach);
 	}
-}
\ No newline at end of file
+}


Test patch:
diff --git a/test/js/samples/bind-width-height/expected.js b/test/js/samples/bind-width-height/expected.js
--- a/test/js/samples/bind-width-height/expected.js
+++ b/test/js/samples/bind-width-height/expected.js
@@ -30,7 +30,7 @@ function create_fragment(ctx) {
 		o: noop,
 		d(detaching) {
 			if (detaching) detach(div);
-			div_resize_listener.cancel();
+			div_resize_listener();
 		}
 	};
 }
diff --git a/test/js/samples/video-bindings/expected.js b/test/js/samples/video-bindings/expected.js
--- a/test/js/samples/video-bindings/expected.js
+++ b/test/js/samples/video-bindings/expected.js
@@ -59,7 +59,7 @@ function create_fragment(ctx) {
 		o: noop,
 		d(detaching) {
 			if (detaching) detach(video);
-			video_resize_listener.cancel();
+			video_resize_listener();
 			run_all(dispose);
 		}
 	};
diff --git a/test/runtime/samples/binding-width-height-a11y/_config.js b/test/runtime/samples/binding-width-height-a11y/_config.js
--- a/test/runtime/samples/binding-width-height-a11y/_config.js
+++ b/test/runtime/samples/binding-width-height-a11y/_config.js
@@ -1,8 +1,8 @@
 export default {
 	async test({ assert, target }) {
-		const object = target.querySelector('object');
+		const iframe = target.querySelector('iframe');
 
-		assert.equal(object.getAttribute('aria-hidden'), "true");
-		assert.equal(object.getAttribute('tabindex'), "-1");
+		assert.equal(iframe.getAttribute('aria-hidden'), "true");
+		assert.equal(iframe.getAttribute('tabindex'), "-1");
 	}
 };