Repository: swc-project/swc (rust)

Issue:
Regression: "this is undefined" when minifying @google/model-viewer with @swc/core 1.4.1 or newer
### Describe the bug

There is a minification bug in @swc/core version 1.4.1 and 1.4.2-nightly-20240216.1. The bug does not appear in @swc/core version 1.4.0. The package used is @google/model-viewer (version 3.4.0).

When the minified code is being run, the following NPE can be observed on the console `this is undefined`.

I have created a repository showcasing the issue: https://github.com/Frank3K/swc-minification-bug-model-viewer.

### Input code

```typescript
@google/model-viewer 3.4.0: 

https://github.com/google/model-viewer/releases/tag/v3.4.0
https://www.npmjs.com/package/@google/model-viewer/v/3.4.0

(Included in minimal reproduction's package.json).
```


### Config

```json
No .swcrc, but these parameters are passed to the minify command:


const { code, map } = await minify(content, {
  module: true,
  ecma: 2015,
});
```


### Playground link (or link to the minimal reproduction)

https://github.com/Frank3K/swc-minification-bug-model-viewer

### SWC Info output


    Operating System:
        Platform: darwin
        Arch: arm64
        Machine Type: arm64
        Version: Darwin Kernel Version 23.3.0: Wed Dec 20 21:30:27 PST 2023; root:xnu-10002.81.5~7/RELEASE_ARM64_T8103
        CPU: (8 cores)
            Models: Apple M1

    Binaries:
        Node: 18.15.0
        npm: 9.5.0
        Yarn: 1.22.19
        pnpm: N/A

    Relevant Packages:
        @swc/core: 1.4.2-nightly-20240216.1
        @swc/helpers: N/A
        @swc/types: 0.1.5
        

    SWC Config:
        output: N/A
        .swcrc path: N/A

    Next.js info:
        output: N/A



### Expected behavior

No NPE occurs, like with version 1.4.0.

### Actual behavior

Code is generated which contains an NPE. Related lines (beautified):

```js
let aU = function(e, t) {
		!1 !== this.enabled && (this.files[e] = t)
	},
	aP = function(e) {
		if (!1 !== this.enabled) return this.files[e]
	},
	aN = function(e) {
		delete this.files[e]
	};
```

Corresponding input source:

```js
const Cache = {

	enabled: false,

	files: {},

	add: function ( key, file ) {

		if ( this.enabled === false ) return;

		// console.log( 'THREE.Cache', 'Adding key:', key );

		this.files[ key ] = file;

	},

	get: function ( key ) {

		if ( this.enabled === false ) return;

		// console.log( 'THREE.Cache', 'Checking key:', key );

		return this.files[ key ];

	},

	remove: function ( key ) {

		delete this.files[ key ];

	},

	clear: function () {

		this.files = {};

	}

};
```

### Version

1.4.2-nightly-20240216.1

### Additional context

_No response_

Pull request description:
fix(es/minifier): Abort property hoister on `this` usage
**Description:**

**Related issue:**

 - Closes #8643

Patch:
diff --git a/crates/swc_ecma_minifier/src/compress/optimize/props.rs b/crates/swc_ecma_minifier/src/compress/optimize/props.rs
index 9e9c12a38a..2d81b9d11c 100644
--- a/crates/swc_ecma_minifier/src/compress/optimize/props.rs
+++ b/crates/swc_ecma_minifier/src/compress/optimize/props.rs
@@ -1,6 +1,6 @@
 use swc_common::{util::take::Take, DUMMY_SP};
 use swc_ecma_ast::*;
-use swc_ecma_utils::{private_ident, prop_name_eq, ExprExt};
+use swc_ecma_utils::{contains_this_expr, private_ident, prop_name_eq, ExprExt};
 
 use super::{unused::PropertyAccessOpts, Optimizer};
 use crate::util::deeply_contains_this_expr;
@@ -222,7 +222,9 @@ impl Optimizer<'_> {
 
 fn is_expr_fine_for_hoist_props(value: &Expr) -> bool {
     match value {
-        Expr::Ident(..) | Expr::Lit(..) | Expr::Arrow(..) | Expr::Fn(..) | Expr::Class(..) => true,
+        Expr::Ident(..) | Expr::Lit(..) | Expr::Arrow(..) | Expr::Class(..) => true,
+
+        Expr::Fn(f) => !contains_this_expr(&f.function.body),
 
         Expr::Unary(u) => match u.op {
             op!("void") | op!("typeof") | op!("!") => is_expr_fine_for_hoist_props(&u.arg),


Test patch:
diff --git a/crates/swc_ecma_minifier/tests/fixture/issues/8643/input.js b/crates/swc_ecma_minifier/tests/fixture/issues/8643/input.js
new file mode 100644
index 0000000000..3539a2b12c
--- /dev/null
+++ b/crates/swc_ecma_minifier/tests/fixture/issues/8643/input.js
@@ -0,0 +1,391 @@
+
+const Cache = {
+
+    enabled: false,
+
+    files: {},
+
+    add: function (key, file) {
+
+        if (this.enabled === false) return;
+
+        // console.log( 'THREE.Cache', 'Adding key:', key );
+
+        this.files[key] = file;
+
+    },
+
+    get: function (key) {
+
+        if (this.enabled === false) return;
+
+        // console.log( 'THREE.Cache', 'Checking key:', key );
+
+        return this.files[key];
+
+    },
+
+    remove: function (key) {
+
+        delete this.files[key];
+
+    },
+
+    clear: function () {
+
+        this.files = {};
+
+    }
+
+};
+
+
+
+
+class Loader {
+
+    constructor(manager) {
+
+        this.manager = (manager !== undefined) ? manager : DefaultLoadingManager;
+
+        this.crossOrigin = 'anonymous';
+        this.withCredentials = false;
+        this.path = '';
+        this.resourcePath = '';
+        this.requestHeader = {};
+
+    }
+
+    load( /* url, onLoad, onProgress, onError */) { }
+
+    loadAsync(url, onProgress) {
+
+        const scope = this;
+
+        return new Promise(function (resolve, reject) {
+
+            scope.load(url, resolve, onProgress, reject);
+
+        });
+
+    }
+
+    parse( /* data */) { }
+
+    setCrossOrigin(crossOrigin) {
+
+        this.crossOrigin = crossOrigin;
+        return this;
+
+    }
+
+    setWithCredentials(value) {
+
+        this.withCredentials = value;
+        return this;
+
+    }
+
+    setPath(path) {
+
+        this.path = path;
+        return this;
+
+    }
+
+    setResourcePath(resourcePath) {
+
+        this.resourcePath = resourcePath;
+        return this;
+
+    }
+
+    setRequestHeader(requestHeader) {
+
+        this.requestHeader = requestHeader;
+        return this;
+
+    }
+
+}
+
+Loader.DEFAULT_MATERIAL_NAME = '__DEFAULT';
+
+const loading = {};
+
+class HttpError extends Error {
+
+    constructor(message, response) {
+
+        super(message);
+        this.response = response;
+
+    }
+
+}
+
+export class FileLoader extends Loader {
+
+    constructor(manager) {
+
+        super(manager);
+
+    }
+
+    load(url, onLoad, onProgress, onError) {
+
+        if (url === undefined) url = '';
+
+        if (this.path !== undefined) url = this.path + url;
+
+        url = this.manager.resolveURL(url);
+
+        const cached = Cache.get(url);
+
+        if (cached !== undefined) {
+
+            this.manager.itemStart(url);
+
+            setTimeout(() => {
+
+                if (onLoad) onLoad(cached);
+
+                this.manager.itemEnd(url);
+
+            }, 0);
+
+            return cached;
+
+        }
+
+        // Check if request is duplicate
+
+        if (loading[url] !== undefined) {
+
+            loading[url].push({
+
+                onLoad: onLoad,
+                onProgress: onProgress,
+                onError: onError
+
+            });
+
+            return;
+
+        }
+
+        // Initialise array for duplicate requests
+        loading[url] = [];
+
+        loading[url].push({
+            onLoad: onLoad,
+            onProgress: onProgress,
+            onError: onError,
+        });
+
+        // create request
+        const req = new Request(url, {
+            headers: new Headers(this.requestHeader),
+            credentials: this.withCredentials ? 'include' : 'same-origin',
+            // An abort controller could be added within a future PR
+        });
+
+        // record states ( avoid data race )
+        const mimeType = this.mimeType;
+        const responseType = this.responseType;
+
+        // start the fetch
+        fetch(req)
+            .then(response => {
+
+                if (response.status === 200 || response.status === 0) {
+
+                    // Some browsers return HTTP Status 0 when using non-http protocol
+                    // e.g. 'file://' or 'data://'. Handle as success.
+
+                    if (response.status === 0) {
+
+                        console.warn('THREE.FileLoader: HTTP Status 0 received.');
+
+                    }
+
+                    // Workaround: Checking if response.body === undefined for Alipay browser #23548
+
+                    if (typeof ReadableStream === 'undefined' || response.body === undefined || response.body.getReader === undefined) {
+
+                        return response;
+
+                    }
+
+                    const callbacks = loading[url];
+                    const reader = response.body.getReader();
+
+                    // Nginx needs X-File-Size check
+                    // https://serverfault.com/questions/482875/why-does-nginx-remove-content-length-header-for-chunked-content
+                    const contentLength = response.headers.get('Content-Length') || response.headers.get('X-File-Size');
+                    const total = contentLength ? parseInt(contentLength) : 0;
+                    const lengthComputable = total !== 0;
+                    let loaded = 0;
+
+                    // periodically read data into the new stream tracking while download progress
+                    const stream = new ReadableStream({
+                        start(controller) {
+
+                            readData();
+
+                            function readData() {
+
+                                reader.read().then(({ done, value }) => {
+
+                                    if (done) {
+
+                                        controller.close();
+
+                                    } else {
+
+                                        loaded += value.byteLength;
+
+                                        const event = new ProgressEvent('progress', { lengthComputable, loaded, total });
+                                        for (let i = 0, il = callbacks.length; i < il; i++) {
+
+                                            const callback = callbacks[i];
+                                            if (callback.onProgress) callback.onProgress(event);
+
+                                        }
+
+                                        controller.enqueue(value);
+                                        readData();
+
+                                    }
+
+                                });
+
+                            }
+
+                        }
+
+                    });
+
+                    return new Response(stream);
+
+                } else {
+
+                    throw new HttpError(`fetch for "${response.url}" responded with ${response.status}: ${response.statusText}`, response);
+
+                }
+
+            })
+            .then(response => {
+
+                switch (responseType) {
+
+                    case 'arraybuffer':
+
+                        return response.arrayBuffer();
+
+                    case 'blob':
+
+                        return response.blob();
+
+                    case 'document':
+
+                        return response.text()
+                            .then(text => {
+
+                                const parser = new DOMParser();
+                                return parser.parseFromString(text, mimeType);
+
+                            });
+
+                    case 'json':
+
+                        return response.json();
+
+                    default:
+
+                        if (mimeType === undefined) {
+
+                            return response.text();
+
+                        } else {
+
+                            // sniff encoding
+                            const re = /charset="?([^;"\s]*)"?/i;
+                            const exec = re.exec(mimeType);
+                            const label = exec && exec[1] ? exec[1].toLowerCase() : undefined;
+                            const decoder = new TextDecoder(label);
+                            return response.arrayBuffer().then(ab => decoder.decode(ab));
+
+                        }
+
+                }
+
+            })
+            .then(data => {
+
+                // Add to cache only on HTTP success, so that we do not cache
+                // error response bodies as proper responses to requests.
+                Cache.add(url, data);
+
+                const callbacks = loading[url];
+                delete loading[url];
+
+                for (let i = 0, il = callbacks.length; i < il; i++) {
+
+                    const callback = callbacks[i];
+                    if (callback.onLoad) callback.onLoad(data);
+
+                }
+
+            })
+            .catch(err => {
+
+                // Abort errors and other errors are handled the same
+
+                const callbacks = loading[url];
+
+                if (callbacks === undefined) {
+
+                    // When onLoad was called and url was deleted in `loading`
+                    this.manager.itemError(url);
+                    throw err;
+
+                }
+
+                delete loading[url];
+
+                for (let i = 0, il = callbacks.length; i < il; i++) {
+
+                    const callback = callbacks[i];
+                    if (callback.onError) callback.onError(err);
+
+                }
+
+                this.manager.itemError(url);
+
+            })
+            .finally(() => {
+
+                this.manager.itemEnd(url);
+
+            });
+
+        this.manager.itemStart(url);
+
+    }
+
+    setResponseType(value) {
+
+        this.responseType = value;
+        return this;
+
+    }
+
+    setMimeType(value) {
+
+        this.mimeType = value;
+        return this;
+
+    }
+
+}
diff --git a/crates/swc_ecma_minifier/tests/fixture/issues/8643/output.js b/crates/swc_ecma_minifier/tests/fixture/issues/8643/output.js
new file mode 100644
index 0000000000..4f161566e9
--- /dev/null
+++ b/crates/swc_ecma_minifier/tests/fixture/issues/8643/output.js
@@ -0,0 +1,145 @@
+const Cache = {
+    enabled: !1,
+    files: {},
+    add: function(key, file) {
+        !1 !== this.enabled && (this.files[key] = file);
+    },
+    get: function(key) {
+        if (!1 !== this.enabled) return this.files[key];
+    }
+};
+class Loader {
+    constructor(manager){
+        this.manager = void 0 !== manager ? manager : DefaultLoadingManager, this.crossOrigin = 'anonymous', this.withCredentials = !1, this.path = '', this.resourcePath = '', this.requestHeader = {};
+    }
+    load() {}
+    loadAsync(url, onProgress) {
+        const scope = this;
+        return new Promise(function(resolve, reject) {
+            scope.load(url, resolve, onProgress, reject);
+        });
+    }
+    parse() {}
+    setCrossOrigin(crossOrigin) {
+        return this.crossOrigin = crossOrigin, this;
+    }
+    setWithCredentials(value) {
+        return this.withCredentials = value, this;
+    }
+    setPath(path) {
+        return this.path = path, this;
+    }
+    setResourcePath(resourcePath) {
+        return this.resourcePath = resourcePath, this;
+    }
+    setRequestHeader(requestHeader) {
+        return this.requestHeader = requestHeader, this;
+    }
+}
+Loader.DEFAULT_MATERIAL_NAME = '__DEFAULT';
+const loading = {};
+class HttpError extends Error {
+    constructor(message, response){
+        super(message), this.response = response;
+    }
+}
+export class FileLoader extends Loader {
+    constructor(manager){
+        super(manager);
+    }
+    load(url, onLoad, onProgress, onError) {
+        void 0 === url && (url = ''), void 0 !== this.path && (url = this.path + url), url = this.manager.resolveURL(url);
+        const cached = Cache.get(url);
+        if (void 0 !== cached) return this.manager.itemStart(url), setTimeout(()=>{
+            onLoad && onLoad(cached), this.manager.itemEnd(url);
+        }, 0), cached;
+        if (void 0 !== loading[url]) {
+            loading[url].push({
+                onLoad: onLoad,
+                onProgress: onProgress,
+                onError: onError
+            });
+            return;
+        }
+        loading[url] = [], loading[url].push({
+            onLoad: onLoad,
+            onProgress: onProgress,
+            onError: onError
+        });
+        const req = new Request(url, {
+            headers: new Headers(this.requestHeader),
+            credentials: this.withCredentials ? 'include' : 'same-origin'
+        }), mimeType = this.mimeType, responseType = this.responseType;
+        fetch(req).then((response)=>{
+            if (200 === response.status || 0 === response.status) {
+                if (0 === response.status && console.warn('THREE.FileLoader: HTTP Status 0 received.'), 'undefined' == typeof ReadableStream || void 0 === response.body || void 0 === response.body.getReader) return response;
+                const callbacks = loading[url], reader = response.body.getReader(), contentLength = response.headers.get('Content-Length') || response.headers.get('X-File-Size'), total = contentLength ? parseInt(contentLength) : 0, lengthComputable = 0 !== total;
+                let loaded = 0;
+                return new Response(new ReadableStream({
+                    start (controller) {
+                        (function readData() {
+                            reader.read().then(({ done, value })=>{
+                                if (done) controller.close();
+                                else {
+                                    const event = new ProgressEvent('progress', {
+                                        lengthComputable,
+                                        loaded: loaded += value.byteLength,
+                                        total
+                                    });
+                                    for(let i = 0, il = callbacks.length; i < il; i++){
+                                        const callback = callbacks[i];
+                                        callback.onProgress && callback.onProgress(event);
+                                    }
+                                    controller.enqueue(value), readData();
+                                }
+                            });
+                        })();
+                    }
+                }));
+            }
+            throw new HttpError(`fetch for "${response.url}" responded with ${response.status}: ${response.statusText}`, response);
+        }).then((response)=>{
+            switch(responseType){
+                case 'arraybuffer':
+                    return response.arrayBuffer();
+                case 'blob':
+                    return response.blob();
+                case 'document':
+                    return response.text().then((text)=>new DOMParser().parseFromString(text, mimeType));
+                case 'json':
+                    return response.json();
+                default:
+                    if (void 0 === mimeType) return response.text();
+                    {
+                        const exec = /charset="?([^;"\s]*)"?/i.exec(mimeType), decoder = new TextDecoder(exec && exec[1] ? exec[1].toLowerCase() : void 0);
+                        return response.arrayBuffer().then((ab)=>decoder.decode(ab));
+                    }
+            }
+        }).then((data)=>{
+            Cache.add(url, data);
+            const callbacks = loading[url];
+            delete loading[url];
+            for(let i = 0, il = callbacks.length; i < il; i++){
+                const callback = callbacks[i];
+                callback.onLoad && callback.onLoad(data);
+            }
+        }).catch((err)=>{
+            const callbacks = loading[url];
+            if (void 0 === callbacks) throw this.manager.itemError(url), err;
+            delete loading[url];
+            for(let i = 0, il = callbacks.length; i < il; i++){
+                const callback = callbacks[i];
+                callback.onError && callback.onError(err);
+            }
+            this.manager.itemError(url);
+        }).finally(()=>{
+            this.manager.itemEnd(url);
+        }), this.manager.itemStart(url);
+    }
+    setResponseType(value) {
+        return this.responseType = value, this;
+    }
+    setMimeType(value) {
+        return this.mimeType = value, this;
+    }
+}
diff --git a/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/contains_this_2/output.js b/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/contains_this_2/output.js
index e4bc28cfa3..5266580b38 100644
--- a/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/contains_this_2/output.js
+++ b/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/contains_this_2/output.js
@@ -1,3 +1,7 @@
-console.log(1, 1, function () {
-    return this === this;
-});
+var o = {
+    u: function() {
+        return this === this;
+    },
+    p: 1
+};
+console.log(o.p, o.p, o.u);
diff --git a/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/new_this/output.js b/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/new_this/output.js
index f9ce8e6b20..d3901b79cf 100644
--- a/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/new_this/output.js
+++ b/crates/swc_ecma_minifier/tests/terser/compress/hoist_props/new_this/output.js
@@ -1,6 +1,8 @@
-console.log(
-    new (function (a) {
+var o = {
+    a: 1,
+    b: 2,
+    f: function(a) {
         this.b = a;
-    })(1).b,
-    2
-);
+    }
+};
+console.log(new o.f(o.a).b, o.b);