Repository: webview/webview (language: python)

Issue:
webview_unbind leaks memory
### What OS are you using (`uname -a`, or Windows version)?

```
$ uname -mrs
Linux 5.15.32_1 x86_64
```

### What programming language are you using (C/C++/Go/Rust)?

```
$ clang --version
clang version 12.0.1
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /bin
```

### What did you expect to see and what you saw instead?

Because of how `webview_bind` works, `webview_unbind` does not actually remove the binding, it merely deletes it after it is created on page init, which requires continually adding more and more JS to the page.
In the (admittedly rather contrived) case where someone is repeatedly binding and unbinding functions, this causes a memory leak.

This can be demonstrated using the following example program:

```c++
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "webview/webview.h"

void foo(const char *seq, const char *req, void *arg) {}

long memory_usage(void) {
	FILE *f = fopen("/proc/self/statm", "r");
	char buf[4096];
	char *line = fgets(buf, sizeof buf, f);
	fclose(f);

	strtok(line, " "); // size
	long resident = atol(strtok(NULL, " "));
	long shared = atol(strtok(NULL, " "));
	return (resident - shared) / 256; // Actual data (not libs) in MiB
}

int main() {
	webview_t w = webview_create(0, NULL);

	webview_bind(w, "foo", foo, NULL);
	webview_unbind(w, "foo");

	printf("%ld\n", memory_usage());

	for (int i = 0; i < 500000; i++) {
		webview_bind(w, "foo", foo, NULL);
		webview_unbind(w, "foo");
	}

	printf("%ld\n", memory_usage());

	webview_destroy(w);
}
```

When run (on Linux) with the following command, it shows the memory usage in MiB after binding and unbinding a single function, and after binding and unbinding the same function 500,000 times:

```
$ clang++ -O0 -g -o test test.cc $(pkg-config --libs --cflags webkit2gtk-4.0) && ./test
9
5741
```

As you can see, the memory usage on startup is only 9MiB, whereas the memory usage after repeatedly binding and unbinding is almost 6GiB.


Discussion / hints:
One potential fix is to, instead of adding each rpc function to the init scripts individually, add a single script when the first function is registered, which calls an "RPC init" native function that then iterates over the map of registered functions and execs the script to load each one.

This means that unbinding no longer has to add init scripts, so the memory leak disappears.

Fix patch:
diff --git a/.clang-tidy b/.clang-tidy
index e07a688ca..a76c9c715 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -43,6 +43,7 @@ Checks: >
     -misc-definitions-in-headers,
     -misc-non-private-member-variables-in-classes,
     -modernize-avoid-c-arrays,
+    -modernize-make-unique,
     -modernize-pass-by-value,
     -modernize-use-nodiscard,
     -modernize-use-override,
diff --git a/examples/bind.c b/examples/bind.c
index b5d658fcc..b4f950d45 100644
--- a/examples/bind.c
+++ b/examples/bind.c
@@ -75,68 +75,71 @@ void thread_sleep(int seconds) {
 
 typedef struct {
   webview_t w;
-  unsigned int count;
+  long count;
 } context_t;
 
-static const char html[] =
-    "<button id=\"increment\">Tap me</button>\n"
-    "<div>You tapped <span id=\"count\">0</span> time(s).</div>\n"
-    "<button id=\"compute\">Compute</button>\n"
-    "<div>Result of computation: <span id=\"compute-result\">0</span></div>\n"
-    "<script>\n"
-    "  const [incrementElement, countElement, computeElement, "
-    "computeResultElement] =\n"
-    "    document.querySelectorAll(\"#increment, #count, #compute, "
-    "#compute-result\");\n"
-    "  document.addEventListener(\"DOMContentLoaded\", () => {\n"
-    "    incrementElement.addEventListener(\"click\", () => {\n"
-    "      window.increment().then(result => {\n"
-    "        countElement.textContent = result.count;\n"
-    "      });\n"
-    "    });\n"
-    "    computeElement.addEventListener(\"click\", () => {\n"
-    "      computeElement.disabled = true;\n"
-    "      window.compute(6, 7).then(result => {\n"
-    "        computeResultElement.textContent = result;\n"
-    "        computeElement.disabled = false;\n"
-    "      });\n"
-    "    });\n"
-    "  });\n"
-    "</script>";
-
-void increment(const char *seq, const char *req, void *arg) {
-  UNUSED(req);
+static const char html[] = "\
+<div>\n\
+  <button id=\"increment\">+</button>\n\
+  <button id=\"decrement\">−</button>\n\
+  <span>Counter: <span id=\"counterResult\">0</span></span>\n\
+</div>\n\
+<hr />\n\
+<div>\n\
+  <button id=\"compute\">Compute</button>\n\
+  <span>Result: <span id=\"computeResult\">(not started)</span></span>\n\
+</div>\n\
+<script type=\"module\">\n\
+  const getElements = ids => Object.assign({}, ...ids.map(\n\
+    id => ({ [id]: document.getElementById(id) })));\n\
+  const ui = getElements([\n\
+    \"increment\", \"decrement\", \"counterResult\", \"compute\",\n\
+    \"computeResult\"\n\
+  ]);\n\
+  ui.increment.addEventListener(\"click\", async () => {\n\
+    ui.counterResult.textContent = await window.count(1);\n\
+  });\n\
+  ui.decrement.addEventListener(\"click\", async () => {\n\
+    ui.counterResult.textContent = await window.count(-1);\n\
+  });\n\
+  ui.compute.addEventListener(\"click\", async () => {\n\
+    ui.compute.disabled = true;\n\
+    ui.computeResult.textContent = \"(pending)\";\n\
+    ui.computeResult.textContent = await window.compute(6, 7);\n\
+    ui.compute.disabled = false;\n\
+  });\n\
+</script>";
+
+void count(const char *id, const char *req, void *arg) {
   context_t *context = (context_t *)arg;
-  char count_string[10] = {0};
-  sprintf(count_string, "%u", ++context->count);
-  char result[21] = {0};
-  strcat(result, "{\"count\": ");
-  strcat(result, count_string);
-  strcat(result, "}");
-  webview_return(context->w, seq, 0, result);
+  // Imagine that params->req is properly parsed or use your own JSON parser.
+  long direction = strtol(req + 1, NULL, 10);
+  char result[10] = {0};
+  sprintf(result, "%ld", context->count += direction);
+  webview_return(context->w, id, 0, result);
 }
 
 typedef struct {
   webview_t w;
-  char *seq;
+  char *id;
   char *req;
 } compute_thread_params_t;
 
 compute_thread_params_t *
-compute_thread_params_create(webview_t w, const char *seq, const char *req) {
+compute_thread_params_create(webview_t w, const char *id, const char *req) {
   compute_thread_params_t *params =
       (compute_thread_params_t *)malloc(sizeof(compute_thread_params_t));
   params->w = w;
-  params->seq = (char *)malloc(strlen(seq) + 1);
+  params->id = (char *)malloc(strlen(id) + 1);
   params->req = (char *)malloc(strlen(req) + 1);
-  strcpy(params->seq, seq);
+  strcpy(params->id, id);
   strcpy(params->req, req);
   return params;
 }
 
 void compute_thread_params_free(compute_thread_params_t *p) {
   free(p->req);
-  free(p->seq);
+  free(p->id);
   free(p);
 }
 
@@ -144,16 +147,16 @@ void compute_thread_proc(void *arg) {
   compute_thread_params_t *params = (compute_thread_params_t *)arg;
   // Simulate load.
   thread_sleep(1);
-  // Either imagine that params->req is parsed here or use your own JSON parser.
+  // Imagine that params->req is properly parsed or use your own JSON parser.
   const char *result = "42";
-  webview_return(params->w, params->seq, 0, result);
+  webview_return(params->w, params->id, 0, result);
   compute_thread_params_free(params);
 }
 
-void compute(const char *seq, const char *req, void *arg) {
+void compute(const char *id, const char *req, void *arg) {
   context_t *context = (context_t *)arg;
   compute_thread_params_t *params =
-      compute_thread_params_create(context->w, seq, req);
+      compute_thread_params_create(context->w, id, req);
   // Create a thread and forget about it for the sake of simplicity.
   if (thread_create(compute_thread_proc, params) != 0) {
     compute_thread_params_free(params);
@@ -175,10 +178,10 @@ int main() {
   webview_set_title(w, "Bind Example");
   webview_set_size(w, 480, 320, WEBVIEW_HINT_NONE);
 
-  // A binding that increments a value and immediately returns the new value.
-  webview_bind(w, "increment", increment, &context);
+  // A binding that counts up or down and immediately returns the new value.
+  webview_bind(w, "count", count, &context);
 
-  // An binding that creates a new thread and returns the result at a later time.
+  // A binding that creates a new thread and returns the result at a later time.
   webview_bind(w, "compute", compute, &context);
 
   webview_set_html(w, html);
diff --git a/examples/bind.cc b/examples/bind.cc
index b2b193ca1..78dca2cfc 100644
--- a/examples/bind.cc
+++ b/examples/bind.cc
@@ -5,26 +5,35 @@
 #include <thread>
 
 constexpr const auto html =
-    R"html(<button id="increment">Tap me</button>
-<div>You tapped <span id="count">0</span> time(s).</div>
-<button id="compute">Compute</button>
-<div>Result of computation: <span id="compute-result">0</span></div>
-<script>
-  const [incrementElement, countElement, computeElement, computeResultElement] =
-    document.querySelectorAll("#increment, #count, #compute, #compute-result");
-  document.addEventListener("DOMContentLoaded", () => {
-    incrementElement.addEventListener("click", () => {
-      window.increment().then(result => {
-        countElement.textContent = result.count;
-      });
-    });
-    computeElement.addEventListener("click", () => {
-      computeElement.disabled = true;
-      window.compute(6, 7).then(result => {
-        computeResultElement.textContent = result;
-        computeElement.disabled = false;
-      });
-    });
+    R"html(
+<div>
+  <button id="increment">+</button>
+  <button id="decrement">−</button>
+  <span>Counter: <span id="counterResult">0</span></span>
+</div>
+<hr />
+<div>
+  <button id="compute">Compute</button>
+  <span>Result: <span id="computeResult">(not started)</span></span>
+</div>
+<script type="module">
+  const getElements = ids => Object.assign({}, ...ids.map(
+    id => ({ [id]: document.getElementById(id) })));
+  const ui = getElements([
+    "increment", "decrement", "counterResult", "compute",
+    "computeResult"
+  ]);
+  ui.increment.addEventListener("click", async () => {
+    ui.counterResult.textContent = await window.count(1);
+  });
+  ui.decrement.addEventListener("click", async () => {
+    ui.counterResult.textContent = await window.count(-1);
+  });
+  ui.compute.addEventListener("click", async () => {
+    ui.compute.disabled = true;
+    ui.computeResult.textContent = "(pending)";
+    ui.computeResult.textContent = await window.compute(6, 7);
+    ui.compute.disabled = false;
   });
 </script>)html";
 
@@ -34,31 +43,30 @@ int WINAPI WinMain(HINSTANCE /*hInst*/, HINSTANCE /*hPrevInst*/,
 #else
 int main() {
 #endif
-  unsigned int count = 0;
-  webview::webview w(false, nullptr);
+  long count = 0;
+
+  webview::webview w(true, nullptr);
   w.set_title("Bind Example");
   w.set_size(480, 320, WEBVIEW_HINT_NONE);
 
-  // A binding that increments a value and immediately returns the new value.
-  w.bind("increment", [&](const std::string & /*req*/) -> std::string {
-    auto count_string = std::to_string(++count);
-    return "{\"count\": " + count_string + "}";
+  // A binding that counts up or down and immediately returns the new value.
+  w.bind("count", [&](const std::string &req) -> std::string {
+    // Imagine that req is properly parsed or use your own JSON parser.
+    auto direction = std::stol(req.substr(1, req.size() - 1));
+    return std::to_string(count += direction);
   });
 
-  // An binding that creates a new thread and returns the result at a later time.
+  // A binding that creates a new thread and returns the result at a later time.
   w.bind(
       "compute",
-      [&](const std::string &seq, const std::string &req, void * /*arg*/) {
+      [&](const std::string &id, const std::string &req, void * /*arg*/) {
         // Create a thread and forget about it for the sake of simplicity.
-        std::thread([&, seq, req] {
+        std::thread([&, id, req] {
           // Simulate load.
           std::this_thread::sleep_for(std::chrono::seconds(1));
-          // json_parse() is an implementation detail and is only used here
-          // to provide a working example.
-          auto left = std::stoll(webview::detail::json_parse(req, "", 0));
-          auto right = std::stoll(webview::detail::json_parse(req, "", 1));
-          auto result = std::to_string(left * right);
-          w.resolve(seq, 0, result);
+          // Imagine that req is properly parsed or use your own JSON parser.
+          const auto *result = "42";
+          w.resolve(id, 0, result);
         }).detach();
       },
       nullptr);
diff --git a/webview.h b/webview.h
index 62ac14b55..0e8f04e98 100644
--- a/webview.h
+++ b/webview.h
@@ -316,19 +316,18 @@ WEBVIEW_API void webview_eval(webview_t w, const char *js);
  * Binds a function pointer to a new global JavaScript function.
  *
  * Internally, JS glue code is injected to create the JS function by the
- * given name. The callback function is passed a sequential request
- * identifier, a request string and a user-provided argument. The request
- * string is a JSON array of the arguments passed to the JS function.
+ * given name. The callback function is passed a request identifier,
+ * a request string and a user-provided argument. The request string is
+ * a JSON array of the arguments passed to the JS function.
  *
  * @param w The webview instance.
  * @param name Name of the JS function.
  * @param fn Callback function.
  * @param arg User argument.
  */
-WEBVIEW_API void webview_bind(webview_t w, const char *name,
-                              void (*fn)(const char *seq, const char *req,
-                                         void *arg),
-                              void *arg);
+WEBVIEW_API void
+webview_bind(webview_t w, const char *name,
+             void (*fn)(const char *id, const char *req, void *arg), void *arg);
 
 /**
  * Removes a binding created with webview_bind().
@@ -342,15 +341,15 @@ WEBVIEW_API void webview_unbind(webview_t w, const char *name);
  * Responds to a binding call from the JS side.
  *
  * @param w The webview instance.
- * @param seq The sequence number of the binding call. Pass along the value
- *            received in the binding handler (see webview_bind()).
+ * @param id The identifier of the binding call. Pass along the value received
+ *           in the binding handler (see webview_bind()).
  * @param status A status of zero tells the JS side that the binding call was
  *               succesful; any other value indicates an error.
  * @param result The result of the binding call to be returned to the JS side.
  *               This must either be a valid JSON value or an empty string for
  *               the primitive JS value @c undefined.
  */
-WEBVIEW_API void webview_return(webview_t w, const char *seq, int status,
+WEBVIEW_API void webview_return(webview_t w, const char *id, int status,
                                 const char *result);
 
 /**
@@ -399,6 +398,7 @@ WEBVIEW_API const webview_version_info_t *webview_version(void);
 #include <cstdint>
 #include <functional>
 #include <future>
+#include <list>
 #include <map>
 #include <string>
 #include <utility>
@@ -888,6 +888,37 @@ class native_library {
   mod_handle_t m_handle{};
 };
 
+class user_script {
+public:
+  class impl;
+
+  user_script(const std::string &code, std::unique_ptr<impl> &&impl)
+      : m_code{code}, m_impl{std::move(impl)} {}
+
+  user_script(const user_script &other) = delete;
+  user_script &operator=(const user_script &other) = delete;
+  user_script(user_script &&other) noexcept { *this = std::move(other); }
+
+  user_script &operator=(user_script &&other) noexcept {
+    if (this == &other) {
+      return *this;
+    }
+    m_code = std::move(other.m_code);
+    m_impl = std::move(other.m_impl);
+    return *this;
+  }
+
+  const std::string &get_code() const { return m_code; }
+
+  impl &get_impl() { return *m_impl; }
+
+  const impl &get_impl() const { return *m_impl; }
+
+private:
+  std::string m_code;
+  std::unique_ptr<impl> m_impl;
+};
+
 class engine_base {
 public:
   virtual ~engine_base() = default;
@@ -915,8 +946,8 @@ class engine_base {
 
   // Synchronous bind
   void bind(const std::string &name, sync_binding_t fn) {
-    auto wrapper = [this, fn](const std::string &seq, const std::string &req,
-                              void * /*arg*/) { resolve(seq, 0, fn(req)); };
+    auto wrapper = [this, fn](const std::string &id, const std::string &req,
+                              void * /*arg*/) { resolve(id, 0, fn(req)); };
     bind(name, wrapper, nullptr);
   }
 
@@ -927,68 +958,37 @@ class engine_base {
       return;
     }
     bindings.emplace(name, binding_ctx_t(fn, arg));
-    auto js = "(function() { var name = '" + name + "';" + R""(
-      var RPC = window._rpc = (window._rpc || {nextSeq: 1});
-      window[name] = function() {
-        var seq = RPC.nextSeq++;
-        var promise = new Promise(function(resolve, reject) {
-          RPC[seq] = {
-            resolve: resolve,
-            reject: reject,
-          };
-        });
-        window.external.invoke(JSON.stringify({
-          id: seq,
-          method: name,
-          params: Array.prototype.slice.call(arguments),
-        }));
-        return promise;
-      }
-    })())"";
-    init(js);
-    eval(js);
+    replace_bind_script();
+    // Notify that a binding was created if the init script has already
+    // set things up.
+    eval("if (window.__webview__) {\n\
+window.__webview__.onBind(" +
+         json_escape(name) + ")\n\
+}");
   }
 
   void unbind(const std::string &name) {
     auto found = bindings.find(name);
-    if (found != bindings.end()) {
-      auto js = "delete window['" + name + "'];";
-      init(js);
-      eval(js);
-      bindings.erase(found);
+    if (found == bindings.end()) {
+      return;
     }
+    bindings.erase(found);
+    replace_bind_script();
+    // Notify that a binding was created if the init script has already
+    // set things up.
+    eval("if (window.__webview__) {\n\
+window.__webview__.onUnbind(" +
+         json_escape(name) + ")\n\
+}");
   }
 
-  void resolve(const std::string &seq, int status, const std::string &result) {
+  void resolve(const std::string &id, int status, const std::string &result) {
     // NOLINTNEXTLINE(modernize-avoid-bind): Lambda with move requires C++14
     dispatch(std::bind(
-        [seq, status, this](std::string escaped_result) {
-          std::string js;
-          js += "(function(){var seq = \"";
-          js += seq;
-          js += "\";\n";
-          js += "var status = ";
-          js += std::to_string(status);
-          js += ";\n";
-          js += "var result = ";
-          js += escaped_result;
-          js += ";\
-var promise = window._rpc[seq];\
-delete window._rpc[seq];\
-if (result !== undefined) {\
-  try {\
-    result = JSON.parse(result);\
-  } catch {\
-    promise.reject(new Error(\"Failed to parse binding result as JSON\"));\
-    return;\
-  }\
-}\
-if (status === 0) {\
-  promise.resolve(result);\
-} else {\
-  promise.reject(result);\
-}\
-})()";
+        [id, status, this](std::string escaped_result) {
+          std::string js = "window.__webview__.onReply(" + json_escape(id) +
+                           ", " + std::to_string(status) + ", " +
+                           escaped_result + ")";
           eval(js);
         },
         result.empty() ? "undefined" : json_escape(result)));
@@ -1007,7 +1007,9 @@ if (status === 0) {\
   }
 
   void set_html(const std::string &html) { set_html_impl(html); }
-  void init(const std::string &js) { init_impl(js); }
+
+  void init(const std::string &js) { add_user_script(js); }
+
   void eval(const std::string &js) { eval_impl(js); }
 
 protected:
@@ -1021,11 +1023,143 @@ if (status === 0) {\
   virtual void set_title_impl(const std::string &title) = 0;
   virtual void set_size_impl(int width, int height, webview_hint_t hints) = 0;
   virtual void set_html_impl(const std::string &html) = 0;
-  virtual void init_impl(const std::string &js) = 0;
   virtual void eval_impl(const std::string &js) = 0;
 
+  virtual user_script *add_user_script(const std::string &js) {
+    return std::addressof(*m_user_scripts.emplace(m_user_scripts.end(),
+                                                  add_user_script_impl(js)));
+  }
+
+  virtual user_script add_user_script_impl(const std::string &js) = 0;
+
+  virtual void
+  remove_all_user_scripts_impl(const std::list<user_script> &scripts) = 0;
+
+  virtual bool are_user_scripts_equal_impl(const user_script &first,
+                                           const user_script &second) = 0;
+
+  virtual user_script *replace_user_script(const user_script &old_script,
+                                           const std::string &new_script_code) {
+    remove_all_user_scripts_impl(m_user_scripts);
+    user_script *old_script_ptr{};
+    for (auto &script : m_user_scripts) {
+      auto is_old_script = are_user_scripts_equal_impl(script, old_script);
+      script = add_user_script_impl(is_old_script ? new_script_code
+                                                  : script.get_code());
+      if (is_old_script) {
+        old_script_ptr = std::addressof(script);
+      }
+    }
+    return old_script_ptr;
+  }
+
+  void replace_bind_script() {
+    if (m_bind_script) {
+      m_bind_script = replace_user_script(*m_bind_script, create_bind_script());
+    } else {
+      m_bind_script = add_user_script(create_bind_script());
+    }
+  }
+
+  void add_init_script(const std::string &post_fn) {
+    add_user_script(create_init_script(post_fn));
+  }
+
+  std::string create_init_script(const std::string &post_fn) {
+    auto js = std::string{} + "(function() {\n\
+  'use strict';\n\
+  function generateId() {\n\
+    var crypto = window.crypto || window.msCrypto;\n\
+    var bytes = new Uint8Array(16);\n\
+    crypto.getRandomValues(bytes);\n\
+    return Array.prototype.slice.call(bytes).map(function(n) {\n\
+      return n.toString(16).padStart(2, '0');\n\
+    }).join('');\n\
+  }\n\
+  var Webview = (function() {\n\
+    var _promises = {};\n\
+    function Webview_() {}\n\
+    Webview_.prototype.post = function(message) {\n\
+      return (" +
+              post_fn + ")(message);\n\
+    };\n\
+    Webview_.prototype.call = function(method) {\n\
+      var _id = generateId();\n\
+      var _params = Array.prototype.slice.call(arguments, 1);\n\
+      var promise = new Promise(function(resolve, reject) {\n\
+        _promises[_id] = { resolve, reject };\n\
+      });\n\
+      this.post(JSON.stringify({\n\
+        id: _id,\n\
+        method: method,\n\
+        params: _params\n\
+      }));\n\
+      return promise;\n\
+    };\n\
+    Webview_.prototype.onReply = function(id, status, result) {\n\
+      var promise = _promises[id];\n\
+      if (result !== undefined) {\n\
+        try {\n\
+          result = JSON.parse(result);\n\
+        } catch {\n\
+          promise.reject(new Error(\"Failed to parse binding result as JSON\"));\n\
+          return;\n\
+        }\n\
+      }\n\
+      if (status === 0) {\n\
+        promise.resolve(result);\n\
+      } else {\n\
+        promise.reject(result);\n\
+      }\n\
+    };\n\
+    Webview_.prototype.onBind = function(name) {\n\
+      if (Object.hasOwn(window, name)) {\n\
+        throw new Error('Property \"' + name + '\" already exists');\n\
+      }\n\
+      window[name] = (function() {\n\
+        var params = [name].concat(Array.prototype.slice.call(arguments));\n\
+        return Webview_.prototype.call.apply(this, params);\n\
+      }).bind(this);\n\
+    };\n\
+    Webview_.prototype.onUnbind = function(name) {\n\
+      if (!Object.hasOwn(window, name)) {\n\
+        throw new Error('Property \"' + name + '\" does not exist');\n\
+      }\n\
+      delete window[name];\n\
+    };\n\
+    return Webview_;\n\
+  })();\n\
+  window.__webview__ = new Webview();\n\
+})()";
+    return js;
+  }
+
+  std::string create_bind_script() {
+    std::string js_names = "[";
+    bool first = true;
+    for (const auto &binding : bindings) {
+      if (first) {
+        first = false;
+      } else {
+        js_names += ",";
+      }
+      js_names += json_escape(binding.first);
+    }
+    js_names += "]";
+
+    auto js = std::string{} + "(function() {\n\
+  'use strict';\n\
+  var methods = " +
+              js_names + ";\n\
+  methods.forEach(function(name) {\n\
+    window.__webview__.onBind(name);\n\
+  });\n\
+})()";
+    return js;
+  }
+
   virtual void on_message(const std::string &msg) {
-    auto seq = json_parse(msg, "id", 0);
+    auto id = json_parse(msg, "id", 0);
     auto name = json_parse(msg, "method", 0);
     auto args = json_parse(msg, "params", 0);
     auto found = bindings.find(name);
@@ -1033,7 +1167,7 @@ if (status === 0) {\
       return;
     }
     const auto &context = found->second;
-    context.callback(seq, args, context.arg);
+    dispatch([=] { context.callback(id, args, context.arg); });
   }
 
   virtual void on_window_created() { inc_window_count(); }
@@ -1063,6 +1197,8 @@ if (status === 0) {\
   }
 
   std::map<std::string, binding_ctx_t> bindings;
+  user_script *m_bind_script{};
+  std::list<user_script> m_user_scripts;
 };
 
 } // namespace detail
@@ -1233,6 +1369,25 @@ constexpr auto webkit_web_view_run_javascript =
         "webkit_web_view_run_javascript");
 } // namespace webkit_symbols
 
+class user_script::impl {
+public:
+  impl(WebKitUserScript *script) : m_script{script} {
+    webkit_user_script_ref(script);
+  }
+
+  ~impl() { webkit_user_script_unref(m_script); }
+
+  impl(const impl &) = delete;
+  impl &operator=(const impl &) = delete;
+  impl(impl &&) = delete;
+  impl &operator=(impl &&) = delete;
+
+  WebKitUserScript *get_native() const { return m_script; }
+
+private:
+  WebKitUserScript *m_script{};
+};
+
 class gtk_webkit_engine : public engine_base {
 public:
   gtk_webkit_engine(bool debug, void *window)
@@ -1256,9 +1411,9 @@ class gtk_webkit_engine : public engine_base {
     webkit_dmabuf::apply_webkit_dmabuf_workaround();
     // Initialize webview widget
     m_webview = webkit_web_view_new();
-    WebKitUserContentManager *manager =
+    WebKitUserContentManager *manager = m_user_content_manager =
         webkit_web_view_get_user_content_manager(WEBKIT_WEB_VIEW(m_webview));
-    g_signal_connect(manager, "script-message-received::external",
+    g_signal_connect(manager, "script-message-received::__webview__",
                      G_CALLBACK(+[](WebKitUserContentManager *,
                                     WebKitJavascriptResult *r, gpointer arg) {
                        auto *w = static_cast<gtk_webkit_engine *>(arg);
@@ -1268,9 +1423,10 @@ class gtk_webkit_engine : public engine_base {
                      }),
                      this);
     webkit_user_content_manager_register_script_message_handler(manager,
-                                                                "external");
-    init("window.external={invoke:function(s){window.webkit.messageHandlers."
-         "external.postMessage(s);}}");
+                                                                "__webview__");
+    add_init_script("function(message) {\n\
+  return window.webkit.messageHandlers.__webview__.postMessage(message);\n\
+}");
 
     gtk_container_add(GTK_CONTAINER(m_window), GTK_WIDGET(m_webview));
     gtk_widget_show(GTK_WIDGET(m_webview));
@@ -1315,6 +1471,7 @@ class gtk_webkit_engine : public engine_base {
     }
   }
 
+protected:
   void *window_impl() override { return (void *)m_window; }
   void *widget_impl() override { return (void *)m_webview; }
   void *browser_controller_impl() override { return (void *)m_webview; };
@@ -1361,17 +1518,11 @@ class gtk_webkit_engine : public engine_base {
                               nullptr);
   }
 
-  void init_impl(const std::string &js) override {
-    WebKitUserContentManager *manager =
-        webkit_web_view_get_user_content_manager(WEBKIT_WEB_VIEW(m_webview));
-    webkit_user_content_manager_add_script(
-        manager,
-        webkit_user_script_new(js.c_str(), WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
-                               WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
-                               nullptr, nullptr));
-  }
-
   void eval_impl(const std::string &js) override {
+    // URI is null before content has begun loading.
+    if (!webkit_web_view_get_uri(WEBKIT_WEB_VIEW(m_webview))) {
+      return;
+    }
     auto &lib = get_webkit_library();
     auto wkmajor = webkit_get_major_version();
     auto wkminor = webkit_get_minor_version();
@@ -1388,6 +1539,29 @@ class gtk_webkit_engine : public engine_base {
     }
   }
 
+  user_script add_user_script_impl(const std::string &js) override {
+    auto *wk_script = webkit_user_script_new(
+        js.c_str(), WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
+        WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nullptr, nullptr);
+    webkit_user_content_manager_add_script(m_user_content_manager, wk_script);
+    user_script script{js, std::unique_ptr<user_script::impl>{
+                               new user_script::impl{wk_script}}};
+    webkit_user_script_unref(wk_script);
+    return script;
+  }
+
+  void remove_all_user_scripts_impl(
+      const std::list<user_script> & /*scripts*/) override {
+    webkit_user_content_manager_remove_all_scripts(m_user_content_manager);
+  }
+
+  bool are_user_scripts_equal_impl(const user_script &first,
+                                   const user_script &second) override {
+    auto *wk_first = first.get_impl().get_native();
+    auto *wk_second = second.get_impl().get_native();
+    return wk_first == wk_second;
+  }
+
 private:
   static char *get_string_from_js_result(WebKitJavascriptResult *r) {
     char *s;
@@ -1448,6 +1622,7 @@ class gtk_webkit_engine : public engine_base {
   bool m_owns_window{};
   GtkWidget *m_window{};
   GtkWidget *m_webview{};
+  WebKitUserContentManager *m_user_content_manager{};
 };
 
 } // namespace detail
@@ -1551,6 +1726,25 @@ inline id operator"" _str(const char *s, std::size_t) {
   return objc::msg_send<id>("NSString"_cls, "stringWithUTF8String:"_sel, s);
 }
 
+class user_script::impl {
+public:
+  impl(id script) : m_script{script} {
+    objc::msg_send<void>(script, "retain"_sel);
+  }
+
+  ~impl() { objc::msg_send<void>(m_script, "release"_sel); }
+
+  impl(const impl &) = delete;
+  impl &operator=(const impl &) = delete;
+  impl(impl &&) = delete;
+  impl &operator=(impl &&) = delete;
+
+  id get_native() const { return m_script; }
+
+private:
+  id m_script{};
+};
+
 class cocoa_wkwebview_engine : public engine_base {
 public:
   cocoa_wkwebview_engine(bool debug, void *window)
@@ -1631,6 +1825,7 @@ class cocoa_wkwebview_engine : public engine_base {
     // has been drained.
   }
 
+protected:
   void *window_impl() override { return (void *)m_window; }
   void *widget_impl() override { return (void *)m_webview; }
   void *browser_controller_impl() override { return (void *)m_webview; };
@@ -1699,18 +1894,13 @@ class cocoa_wkwebview_engine : public engine_base {
                                             html.c_str()),
                          nullptr);
   }
-  void init_impl(const std::string &js) override {
-    objc::autoreleasepool arp;
-    auto script = objc::autoreleased(objc::msg_send<id>(
-        objc::msg_send<id>("WKUserScript"_cls, "alloc"_sel),
-        "initWithSource:injectionTime:forMainFrameOnly:"_sel,
-        objc::msg_send<id>("NSString"_cls, "stringWithUTF8String:"_sel,
-                           js.c_str()),
-        WKUserScriptInjectionTimeAtDocumentStart, YES));
-    objc::msg_send<void>(m_manager, "addUserScript:"_sel, script);
-  }
   void eval_impl(const std::string &js) override {
     objc::autoreleasepool arp;
+    // URI is null before content has begun loading.
+    auto nsurl = objc::msg_send<id>(m_webview, "URL"_sel);
+    if (!nsurl) {
+      return;
+    }
     objc::msg_send<void>(m_webview, "evaluateJavaScript:completionHandler:"_sel,
                          objc::msg_send<id>("NSString"_cls,
                                             "stringWithUTF8String:"_sel,
@@ -1718,6 +1908,36 @@ class cocoa_wkwebview_engine : public engine_base {
                          nullptr);
   }
 
+  user_script add_user_script_impl(const std::string &js) override {
+    objc::autoreleasepool arp;
+    auto wk_script = objc::msg_send<id>(
+        objc::msg_send<id>("WKUserScript"_cls, "alloc"_sel),
+        "initWithSource:injectionTime:forMainFrameOnly:"_sel,
+        objc::msg_send<id>("NSString"_cls, "stringWithUTF8String:"_sel,
+                           js.c_str()),
+        WKUserScriptInjectionTimeAtDocumentStart, YES);
+    // Script is retained when added.
+    objc::msg_send<void>(m_manager, "addUserScript:"_sel, wk_script);
+    user_script script{js, std::unique_ptr<user_script::impl>{
+                               new user_script::impl{wk_script}}};
+    objc::msg_send<void>(wk_script, "release"_sel);
+    return script;
+  }
+
+  void remove_all_user_scripts_impl(
+      const std::list<user_script> & /*scripts*/) override {
+    objc::autoreleasepool arp;
+    // Removing scripts decreases the retain count of each script.
+    objc::msg_send<id>(m_manager, "removeAllUserScripts"_sel);
+  }
+
+  bool are_user_scripts_equal_impl(const user_script &first,
+                                   const user_script &second) override {
+    auto *wk_first = first.get_impl().get_native();
+    auto *wk_second = second.get_impl().get_native();
+    return wk_first == wk_second;
+  }
+
 private:
   id create_app_delegate() {
     objc::autoreleasepool arp;
@@ -1986,15 +2206,11 @@ class cocoa_wkwebview_engine : public engine_base {
     auto script_message_handler =
         objc::autoreleased(create_script_message_handler());
     objc::msg_send<void>(m_manager, "addScriptMessageHandler:name:"_sel,
-                         script_message_handler, "external"_str);
+                         script_message_handler, "__webview__"_str);
 
-    init(R""(
-      window.external = {
-        invoke: function(s) {
-          window.webkit.messageHandlers.external.postMessage(s);
-        },
-      };
-      )"");
+    add_init_script("function(message) {\n\
+  return window.webkit.messageHandlers.__webview__.postMessage(message);\n\
+}");
   }
   void stop_run_loop() {
     objc::autoreleasepool arp;
@@ -2557,6 +2773,12 @@ static constexpr IID IID_ICoreWebView2WebMessageReceivedEventHandler{
     0x00E6,
     0x49FA,
     {0x8E, 0x07, 0x89, 0x8E, 0xA0, 0x1E, 0xCB, 0xD2}};
+static constexpr IID
+    IID_ICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler{
+        0xB99369F3,
+        0x9B11,
+        0x47B5,
+        {0xBC, 0x6F, 0x8E, 0x78, 0x95, 0xFC, 0xEA, 0x17}};
 
 #if WEBVIEW_MSWEBVIEW2_BUILTIN_IMPL == 1
 enum class webview2_runtime_type { installed = 0, embedded = 1 };
@@ -2805,9 +3027,32 @@ static constexpr auto message_received =
 static constexpr auto permission_requested =
     cast_info_t<ICoreWebView2PermissionRequestedEventHandler>{
         IID_ICoreWebView2PermissionRequestedEventHandler};
+
+static constexpr auto add_script_to_execute_on_document_created_completed =
+    cast_info_t<
+        ICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler>{
+        IID_ICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler};
 } // namespace cast_info
 } // namespace mswebview2
 
+// Checks whether the specified IID equals the IID of the specified type and
+// if so casts the "this" pointer to T and returns it. Returns nullptr on
+// mismatching IIDs.
+// If ppv is specified then the pointer will also be assigned to *ppv.
+template <typename From, typename To>
+To *cast_if_equal_iid(From *from, REFIID riid, const cast_info_t<To> &info,
+                      LPVOID *ppv = nullptr) noexcept {
+  To *ptr = nullptr;
+  if (IsEqualIID(riid, info.iid)) {
+    ptr = static_cast<To *>(from);
+    ptr->AddRef();
+  }
+  if (ppv) {
+    *ppv = ptr;
+  }
+  return ptr;
+}
+
 class webview2_com_handler
     : public ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler,
       public ICoreWebView2CreateCoreWebView2ControllerCompletedHandler,
@@ -2851,10 +3096,10 @@ class webview2_com_handler
     // that it is the only interface requested in this case. None have been
     // observed to be requested when using the official WebView2 loader.
 
-    if (cast_if_equal_iid(riid, controller_completed, ppv) ||
-        cast_if_equal_iid(riid, environment_completed, ppv) ||
-        cast_if_equal_iid(riid, message_received, ppv) ||
-        cast_if_equal_iid(riid, permission_requested, ppv)) {
+    if (cast_if_equal_iid(this, riid, controller_completed, ppv) ||
+        cast_if_equal_iid(this, riid, environment_completed, ppv) ||
+        cast_if_equal_iid(this, riid, message_received, ppv) ||
+        cast_if_equal_iid(this, riid, permission_requested, ppv)) {
       return S_OK;
     }
 
@@ -2894,12 +3139,14 @@ class webview2_com_handler
     m_cb(controller, webview);
     return S_OK;
   }
-  HRESULT STDMETHODCALLTYPE Invoke(
-      ICoreWebView2 *sender, ICoreWebView2WebMessageReceivedEventArgs *args) {
-    LPWSTR message;
-    args->TryGetWebMessageAsString(&message);
-    m_msgCb(narrow_string(message));
-    sender->PostWebMessageAsString(message);
+  HRESULT STDMETHODCALLTYPE
+  Invoke(ICoreWebView2 * /*sender*/,
+         ICoreWebView2WebMessageReceivedEventArgs *args) {
+    LPWSTR message{};
+    auto res = args->TryGetWebMessageAsString(&message);
+    if (SUCCEEDED(res)) {
+      m_msgCb(narrow_string(message));
+    }
 
     CoTaskMemFree(message);
     return S_OK;
@@ -2915,24 +3162,6 @@ class webview2_com_handler
     return S_OK;
   }
 
-  // Checks whether the specified IID equals the IID of the specified type and
-  // if so casts the "this" pointer to T and returns it. Returns nullptr on
-  // mismatching IIDs.
-  // If ppv is specified then the pointer will also be assigned to *ppv.
-  template <typename T>
-  T *cast_if_equal_iid(REFIID riid, const cast_info_t<T> &info,
-                       LPVOID *ppv = nullptr) noexcept {
-    T *ptr = nullptr;
-    if (IsEqualIID(riid, info.iid)) {
-      ptr = static_cast<T *>(this);
-      ptr->AddRef();
-    }
-    if (ppv) {
-      *ppv = ptr;
-    }
-    return ptr;
-  }
-
   // Set the function that will perform the initiating logic for creating
   // the WebView2 environment.
   void set_attempt_handler(std::function<HRESULT()> attempt_handler) noexcept {
@@ -2975,6 +3204,76 @@ class webview2_com_handler
   unsigned int m_attempts = 0;
 };
 
+class webview2_user_script_added_handler
+    : public ICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler {
+public:
+  using callback_fn = std::function<void(HRESULT errorCode, LPCWSTR id)>;
+
+  webview2_user_script_added_handler(callback_fn cb) : m_cb{cb} {}
+
+  virtual ~webview2_user_script_added_handler() = default;
+  webview2_user_script_added_handler(
+      const webview2_user_script_added_handler &other) = delete;
+  webview2_user_script_added_handler &
+  operator=(const webview2_user_script_added_handler &other) = delete;
+  webview2_user_script_added_handler(
+      webview2_user_script_added_handler &&other) = delete;
+  webview2_user_script_added_handler &
+  operator=(webview2_user_script_added_handler &&other) = delete;
+
+  ULONG STDMETHODCALLTYPE AddRef() { return ++m_ref_count; }
+  ULONG STDMETHODCALLTYPE Release() {
+    if (m_ref_count > 1) {
+      return --m_ref_count;
+    }
+    delete this;
+    return 0;
+  }
+
+  HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv) {
+    using namespace mswebview2::cast_info;
+
+    if (!ppv) {
+      return E_POINTER;
+    }
+
+    if (cast_if_equal_iid(this, riid,
+                          add_script_to_execute_on_document_created_completed,
+                          ppv)) {
+      return S_OK;
+    }
+
+    return E_NOINTERFACE;
+  }
+
+  HRESULT STDMETHODCALLTYPE Invoke(HRESULT res, LPCWSTR id) {
+    m_cb(res, id);
+    return S_OK;
+  }
+
+private:
+  callback_fn m_cb;
+  std::atomic<ULONG> m_ref_count{1};
+};
+
+class user_script::impl {
+public:
+  impl(const std::wstring &id, const std::wstring &code)
+      : m_id{id}, m_code{code} {}
+
+  impl(const impl &) = delete;
+  impl &operator=(const impl &) = delete;
+  impl(impl &&) = delete;
+  impl &operator=(impl &&) = delete;
+
+  const std::wstring &get_id() const { return m_id; }
+  const std::wstring &get_code() const { return m_code; }
+
+private:
+  std::wstring m_id;
+  std::wstring m_code;
+};
+
 class win32_edge_engine : public engine_base {
 public:
   win32_edge_engine(bool debug, void *window) : m_owns_window{!window} {
@@ -3244,6 +3543,7 @@ class win32_edge_engine : public engine_base {
   win32_edge_engine(win32_edge_engine &&other) = delete;
   win32_edge_engine &operator=(win32_edge_engine &&other) = delete;
 
+protected:
   void run_impl() override {
     MSG msg;
     while (GetMessageW(&msg, nullptr, 0, 0) > 0) {
@@ -3296,12 +3596,9 @@ class win32_edge_engine : public engine_base {
     m_webview->Navigate(wurl.c_str());
   }
 
-  void init_impl(const std::string &js) override {
-    auto wjs = widen_string(js);
-    m_webview->AddScriptToExecuteOnDocumentCreated(wjs.c_str(), nullptr);
-  }
-
   void eval_impl(const std::string &js) override {
+    // TODO: Skip if no content has begun loading yet. Can't check with
+    //       ICoreWebView2::get_Source because it returns "about:blank".
     auto wjs = widen_string(js);
     m_webview->ExecuteScript(wjs.c_str(), nullptr);
   }
@@ -3310,6 +3607,45 @@ class win32_edge_engine : public engine_base {
     m_webview->NavigateToString(widen_string(html).c_str());
   }
 
+  user_script add_user_script_impl(const std::string &js) override {
+    auto wjs = widen_string(js);
+    std::wstring script_id;
+    bool done{};
+    webview2_user_script_added_handler handler{[&](HRESULT res, LPCWSTR id) {
+      if (SUCCEEDED(res)) {
+        script_id = id;
+      }
+      done = true;
+    }};
+    auto res =
+        m_webview->AddScriptToExecuteOnDocumentCreated(wjs.c_str(), &handler);
+    if (SUCCEEDED(res)) {
+      // Sadly we need to pump the even loop in order to get the script ID.
+      while (!done) {
+        deplete_run_loop_event_queue();
+      }
+    }
+    // TODO: There's a non-zero chance that we didn't get the script ID.
+    //       We need to convey the error somehow.
+    return user_script{js, std::unique_ptr<user_script::impl>{
+                               new user_script::impl{script_id, wjs}}};
+  }
+
+  void
+  remove_all_user_scripts_impl(const std::list<user_script> &scripts) override {
+    for (const auto &script : scripts) {
+      const auto &id = script.get_impl().get_id();
+      m_webview->RemoveScriptToExecuteOnDocumentCreated(id.c_str());
+    }
+  }
+
+  bool are_user_scripts_equal_impl(const user_script &first,
+                                   const user_script &second) override {
+    const auto &first_id = first.get_impl().get_id();
+    const auto &second_id = second.get_impl().get_id();
+    return first_id == second_id;
+  }
+
 private:
   bool embed(HWND wnd, bool debug, msg_cb_t cb) {
     std::atomic_flag flag = ATOMIC_FLAG_INIT;
@@ -3377,7 +3713,9 @@ class win32_edge_engine : public engine_base {
     if (res != S_OK) {
       return false;
     }
-    init("window.external={invoke:s=>window.chrome.webview.postMessage(s)}");
+    add_init_script("function(message) {\n\
+  return window.chrome.webview.postMessage(message);\n\
+}");
     resize_webview();
     m_controller->put_IsVisible(TRUE);
     ShowWindow(m_widget, SW_SHOW);
@@ -3569,13 +3907,13 @@ WEBVIEW_API void webview_eval(webview_t w, const char *js) {
 }
 
 WEBVIEW_API void webview_bind(webview_t w, const char *name,
-                              void (*fn)(const char *seq, const char *req,
+                              void (*fn)(const char *id, const char *req,
                                          void *arg),
                               void *arg) {
   static_cast<webview::webview *>(w)->bind(
       name,
-      [=](const std::string &seq, const std::string &req, void *arg) {
-        fn(seq.c_str(), req.c_str(), arg);
+      [=](const std::string &id, const std::string &req, void *arg) {
+        fn(id.c_str(), req.c_str(), arg);
       },
       arg);
 }
@@ -3584,9 +3922,9 @@ WEBVIEW_API void webview_unbind(webview_t w, const char *name) {
   static_cast<webview::webview *>(w)->unbind(name);
 }
 
-WEBVIEW_API void webview_return(webview_t w, const char *seq, int status,
+WEBVIEW_API void webview_return(webview_t w, const char *id, int status,
                                 const char *result) {
-  static_cast<webview::webview *>(w)->resolve(seq, status, result);
+  static_cast<webview::webview *>(w)->resolve(id, status, result);
 }
 
 WEBVIEW_API const webview_version_info_t *webview_version(void) {


Test patch:
diff --git a/webview_test.cc b/webview_test.cc
index b81fecf7c..d5a6e37f2 100644
--- a/webview_test.cc
+++ b/webview_test.cc
@@ -288,7 +288,7 @@ static void test_bidir_comms() {
     switch (i) {
     case 0:
       assert(msg == "loaded");
-      w->eval("window.external.invoke('exiting ' + window.x)");
+      w->eval("window.__webview__.post('exiting ' + window.x)");
       break;
     case 1:
       assert(msg == "exiting 42");
@@ -301,7 +301,7 @@ static void test_bidir_comms() {
   browser.init(R"(
     window.x = 42;
     window.onload = () => {
-      window.external.invoke('loaded');
+      window.__webview__.post('loaded');
     };
   )");
   browser.navigate("data:text/html,%3Chtml%3Ehello%3C%2Fhtml%3E");