0

The WebKit-based WKWebView documentation provides a callAsyncJavaScript() method with the following example:

var p = new Promise(function (f) {
   window.setTimeout("f(42)", 1000);
});
await p;
return p;

Various attempts to run this example code pattern return the same "Completion handler for function call is no longer reachable" error:

Error Domain=WKErrorDomain Code=4 
"A JavaScript exception occurred" 
UserInfo={
  WKJavaScriptExceptionLineNumber=0, 
  WKJavaScriptExceptionMessage="Completion handler for function call is no longer reachable", 
  WKJavaScriptExceptionColumnNumber=0, 
  NSLocalizedDescription="A JavaScript exception occurred"
}

I've tried several tweaks of the example code with callAsyncJavaScript. The callAsyncJavaScript call always returns with the same "... no longer reachable" error.

Here one variant which runs without error in the browser JS console, that does not work with callAsyncJavaScript:

enter image description here

Why the error? Can the example somehow successfully run with WKWebView callAsyncJavaScript? If yes, then how?


Additional detail

The issue may be that the Promise does not properly resolve in the documentation example.

The code below has added console.log() tracing information.

let tracer = "a"
console.log("tracer:", tracer);

f = function (nProperty) {
  let nResult = nProperty * 2
  console.log("nResult:", nResult);
  tracer = tracer + "d"
  console.log("tracer:", tracer);
  return nResult;
};

var p = new Promise(function (f) {
  tracer = tracer + "b"
  console.log("tracer:", tracer);
  window.setTimeout("f(42)", 500);
});

console.log("BEFORE await:", p);
tracer = tracer + "c"
console.log("tracer:", tracer);
await p;

console.log("AFTER await:", p);
tracer = tracer + "e"
console.log("tracer:", tracer);

When above is run in the a Firefox JS console, the execution stops at the await statement. Here's the Firefox JS console output:

tracer: a
tracer: ab
BEFORE await: Promise { <state>: "pending" }
tracer: abc
nResult: 84
tracer: abcd

The statement console.log("AFTER await:", p); is never executed.


And, one more detail...

From a Swift programmer point of view, one would expect that a successful JavaScript example which is called from the Swift callAsyncJavaScript(…) would expressly return a useful result from the JavaScript back to the Swift Result<Any, Error>).

Note that callAsyncJavaScript(…) executes a provided String of JavaScript as an asynchronous JavaScript function.

// do some JavaScript actions, await a result, and then..
return somthingUseful;
marc-medley
  • 8,931
  • 5
  • 60
  • 66
  • 1
    Probably (?) not related but `window.setTimeout("f(42)", 1000);` is very bad practice. Don't pass strings to `setTimeout`, you don't need that code `eval`-ed. You just need `window.setTimeout(f, 1000, 42);` or `window.setTimeout(() => f(42), 1000);` See: [How can I pass a parameter to a setTimeout() callback?](https://stackoverflow.com/q/1190642) – VLAZ Jun 28 '21 at 07:35
  • 1
    Also not very related but `await p; return p;` doesn't make much sense. `p` is still a promise, so you just await its completion and then return the same promise. – VLAZ Jun 28 '21 at 07:38
  • @VLAZ Agreed, the example could be improved in several ways. The "not working" part appears to be that the `Promise` was not properly _resolved_, as updated in the question. I did manage to create a working example which is posted as an answer ... although it doesn't look much like the original example. – marc-medley Jun 28 '21 at 20:58
  • I'm sorry, I didn't actually realise that `window.setTimeout("f(42)", 1000);` and the `await p; return p;` came directly from the documentation. For the record, the documentation is completely wrong *and* misleading here. I'll write an answer to explain why their example with the Promise doesn't work in particular. – VLAZ Jun 30 '21 at 10:15

2 Answers2

2

The issue with the "Completion handler for function call is no longer reachable" error in the callAsyncJavaScript() documentation example may possibly be that the Promise did not resolve.

Here is a working callAsyncJavaScript example (Swift 5.4, Xcode 15.5, macOS 11.4) which uses Promise, setTimeout, await and resolves to a returned data object.

function getPromise() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve({
              'word': 'epistemology'
            });
        }, 2000);
    });
}

function getResult() {
    return getPromise()
        .then(function(response) {
            return response;
        });
}

let data = await getResult()
    .then(function(result) {
        return result;
    });

return data;

Update

Read the answer that VLAZ provided to understand the real reason why "f(32)" broke the example provided in Apple's callAsyncJavaScript() documentation (as written at the time of this question).

Also, the Apple example does run to completion and return a 42 value in both the FireFox JavaScript console and the Swift callAsyncJavaScript(…) method when the example is modified per the answer provide by VLAZ.

The return value from JavaScript to Swift, as seen in the Swift LLDB debugger:

(lldb) po result
▿ Result<Any, Error>
  - success : 42

Addendum: Some Useful Tips

The Swift callAsyncJavaScript() method executes the provided JavaScript String as an asynchronous JavaScript function inside a WebKit instance. As such, a JavaScript debugger and console.log() are not available to the Swift callAsyncJavaScript() method.

Below is an approach which can help provide debugging visibilty to a planned JavaScript String.

The FireFox JavaScript console can used to exercise the string inside an async function body:

// --- name browser window/tab ---
document.title = "Example 42";

async function callAsyncJavaScript() {
  // --- begin Swift string literal section ---
  let mocklog = "LOG: ";
  
  var p = new Promise(function (f) {
    mocklog += "A ";  
    window.setTimeout(() => f(42), 1000); 
  });
  
  // p: Promise { <state>: "pending" }
  mocklog += "B ";  
  let pResult = await p;
  
  // p: Promise { <state>: "fulfilled", <value>: 42 }
  mocklog += `C ${pResult}`;
  return {"pResult": pResult, "mocklog": mocklog}
  // --- end Swift string literal section ---
}

let result = await callAsyncJavaScript();
console.log("Result: ", result)
// Result: Object { pResult: 42, mocklog: "LOG: A B C 42" }

The same JavaScript can be placed in a multi-line Swift String literal:

let javaScript_42 = """
    let mocklog = "LOG: ";
    
    var p = new Promise(function (f) {
      mocklog += "A ";
      window.setTimeout(() => f(42), 1000);
    });
    
    // p: Promise { <state>: "pending" }
    mocklog += "B ";
    let pResult = await p;
    
    // p: Promise { <state>: "fulfilled", <value>: 42 }
    mocklog += `C ${pResult}`;
    return {"pResult": pResult, "mocklog": mocklog}
    """

Swift debugger result:

(lldb) po result
▿ Result<Any, Error>
  ▿ success : 2 elements
    ▿ 0 : 2 elements
      - key : pResult
      - value : 42
    ▿ 1 : 2 elements
      - key : mocklog
      - value : LOG: A B C 42

Footnote: A mocklog string was added as a substitute for console.log() since console.log is not available in the Swift callAsyncJavaScript(). A \n in a regular JavaString mocklog string would be \\n in a corresponding Swift multiline String literal.

marc-medley
  • 8,931
  • 5
  • 60
  • 66
1

The documentation has a really big problem because the code it shows straight up does not work nor can it work.

var p = new Promise(function (f) {
   window.setTimeout("f(42)", 1000);
});

First of all, this is very bad practice. Do not pass strings to setTimeout as they will be evaluated as code and that is dangerous and very error prone. Pass a function instead.

The current issue comes that when you pass a string it will not be evaluated in the current scope but the global scope:

const foo = "global";

function test() {
  const foo = "local";
  window.setTimeout("console.log(foo)", 1000);
}

test();

Which means that window.setTimeout("f(42)", 1000); will not call the function parameter f (the resolve function) but attempt to use a global variable called f and most likely fail because it does not exist. The promise then is left forever pending.

var p = new Promise(function (f) {
   window.setTimeout("f(42)", 1000);
});

p
  .then(value => console.log(`Completed successfully, value: ${value}`)) 
  .catch(error => console.log(`Promise resulted in error`, error));

//neither of the above triggers because of the error in the console

The correct ways to create a promise that will resolve later with setTimeout are:

var p1 = new Promise(function (f) {
   window.setTimeout(() => f(42), 1000);
});

var p2 = new Promise(function (f) {
   window.setTimeout(f, 1000, 42);
});

var p3 = new Promise(function (f) {
   window.setTimeout(f.bind(null, 42), 1000);
});


p1.then(value => console.log(`p1: Completed successfully, value: ${value}`));
p2.then(value => console.log(`p2: Completed successfully, value: ${value}`));
p3.then(value => console.log(`p3: Completed successfully, value: ${value}`));

See: How can I pass a parameter to a setTimeout() callback?

VLAZ
  • 26,331
  • 9
  • 49
  • 67
  • Note: As a Swift developer (and in the Apple `callAsyncJavaScript` doc examples) there is an _implied expectation_ that a working JavaScript example expressly `return somthingUseful;`. The question was updated to express the tacit expectation. – marc-medley Jul 01 '21 at 18:32
  • This answer is accepted because it clearly explains why `"f(42)"` breaks the documentation example. Separately, I've added complementary detail to my answer that a Swift developer might find of practical use. Thanks for contributing your insight. – marc-medley Jul 01 '21 at 18:32