◆ リスナ設定が非同期になって Worker 作成から同期的に postMessage したものが無視される
◆ トップレベル await より前にリスナをつけるか Worker の準備完了後にイベントを送るなどで対処

Worker の準備ができるのが非同期化する

Web Worker を使うとき Worker を作ってそのまま postMessage する感じで同期的に処理していました

[page.js]
const worker = new Worker("./worker.js", { type: "module" })

worker.addEventListener("message", (msg) => {
const data = msg.data
// ...
})

worker.postMessage({ foo: "bar" })

[worker.js]
self.onmessage = async (msg) => {
console.log("message received")
const data = msg.data
// ...
}

これは問題なく動きます
ログに message received が出ます

しかし worker.js でトップレベル await があると動かなくなります

[worker.js]
await new Promise(r => setTimeout(r, 100))

self.onmessage = async (msg) => {
console.log("message received")
const data = msg.data
// ...
}

self.onmessage にリスナを設定するより先に postMessage のメッセージを受け取っていてリスナで処理されないようです
import するファイルの中にトップレベル await をするものがあれば影響するので 自分ではトップレベル await を使っていなくてもライブラリが使っていたら影響を受けます

[worker.js]
import "./mod.js"

self.onmessage = async (msg) => {
console.log("message received")
const data = msg.data
// ...
}

[mod.js]
await new Promise(r => setTimeout(r, 100))

この場合でもログに message received が表示されません

ただ worker.js 内で静的インポートすること自体は影響しません
トップレベルで await せず mod.js が数十 MB のサイズで通信に数秒かかってもログに message received が表示されます

[page.js]
console.log(new Date().toLocaleTimeString(), "p-1")
const worker = new Worker("./worker.js", { type: "module" })
console.log(new Date().toLocaleTimeString(), "p-2")

worker.addEventListener("message", (msg) => {
const data = msg.data
// ...
})

worker.postMessage({ foo: "bar" })
console.log(new Date().toLocaleTimeString(), "p-3")

[worker.js]
import "./mod.js"

console.log(new Date().toLocaleTimeString(), "w-1")

self.onmessage = async (msg) => {
console.log(new Date().toLocaleTimeString(), "w-2")
console.log("message received")
const data = msg.data
// ...
}

[mod.js]
// ここにかなーり長いテキスト数十 MB 分

2:52:12 p-1
2:52:12 p-2
2:52:12 p-3
2:52:14 w-1
2:52:14 w-2
message received

意外なことに p-3 までは先に終わってます
postMessage で送信されたものが Worker 側に伝わるのが Worker 側のインポートが終わって準備ができてからになってるようです
それならトップレベル await も終わってから準備完了とみなしてくれれば良いのですが そうはなってないようです

対処方法

対処方法のひとつは worker.js 内の import を動的にしてリスナを最初につけることです

[worker.js]
let mod1, mod2

const ready = Promise.all([
import("./mod1.js"),
import("./mod2.js"),
]).then(modules => {
;[mod1, mod2] = modules
})

self.onmessage = async (msg) => {
await ready
console.log("message received")
const data = msg.data
// ...
}

mod1.js や mod2.js の中でトップレベル await があっても それを待たずにリスナをつけるのでイベントを漏らさないです
ただリスナの処理の中で動的インポートの完了を待つ必要があります

別の方法だと Worker の準備ができたら Worker 側からイベントを起こします
そのイベントを受け取ったらページ側は Worker の準備ができたと判断してメッセージを送ります
こっちのほうが正当な感じがします

[page.js]
const worker = new Worker("./worker.js", { type: "module" })

await new Promise(resolve => {
worker.addEventListener("message", (msg) => {
const data = msg.data

if (data?.ready) {
resolve()
return
}

// ...
})
})

worker.postMessage({ foo: "bar" })

[worker.js]
import init from "./mod.js"

await init()

self.onmessage = async (msg) => {
console.log(new Date().toLocaleTimeString(), "w-2")
console.log("message received")
const data = msg.data
// ...
}

self.postMessage({ ready: true })

トップレベル await の時間による違い

トップレベル await の処理ですが

await Promise.resolve()

みたいなマイクロタスクだったり

await new Promise(r => setTimeout(r, 0))

みたいな 0 秒タイムアウトの場合は同期実行と同じようにイベントを受け取れました

タイムアウトが 1ms になるとイベントを受け取れなくなりました

await new Promise(r => setTimeout(r, 1))

Worker は別スレッドなのでタイミングによるものかもと思って 別の PC を試したり setTimeout を 0 秒にした上で同期処理で 1 秒くらい待機させてから setTimeout させるなどしても結果は同じでした
内部的に Worker の準備完了を判断する処理でそうなってそうです

Worker のエラー

ところで 以前も Worker を使った場合におかしな挙動のところがあると書いたことありましたが やっぱり Worker はあまり使われないからかメインのページ側よりもバグがありますね
import 時の構文エラー時にエラーが表示されないです

[worker.js]
import value from "./mod.js"
console.log("ok")

[mod.js]
export default 1

これは問題なく動くものです
mod.js で throw するとコンソールに Uncaught Error が表示されます

[mod.js]
throw new Error("ERROR")
export default 1

しかし 構文エラーがあったり

[mod.js]
console.log(1
export default 1

export されてないものを import しようとしたりすると
(これもエラーの種類は構文エラーです)

[mod.js]
console.log(1)

何もコンソールに表示されないです
エラーになってるので ok も表示されません

なぜか動かないときに原因を探すのがつらいのでエラーはちゃんと表示してほしいものです

追記 1

エラー時に何もコンソールに出ない問題 書いた後にもしかしてと思って error イベントをリッスンしてみるとここで取れました

const worker = new Worker("./worker.js", { type: "module" })
worker.addEventListener("error", console.error)

取れたのはいいのですが Error のインスタンスが入っていないようなのでエラーが起きたことしか分からず原因がわからないのに変わりありません
throw したものだとコンソールに出るので リスナ設定しないでもコンソールには出してほしいです

追記 2

見直してて Worker から準備できたことを知らせてもらうところのコードをもう少しよくできるかもと思ったので追記です
元のコードがこれ

const worker = new Worker("./worker.js", { type: "module" })

await new Promise(resolve => {
worker.addEventListener("message", (msg) => {
const data = msg.data

if (data?.ready) {
resolve()
return
}

// ...
})
})

worker.postMessage({ foo: "bar" })

初回限定の ready 判定と その他のメインのデータ受信が混ざってるんですよね
用途が違うので分けたい気持ちがあります
それに if 文 1 つとはいえ 2 回目以降は不要な if 文を毎回通るのもなんか気持ち悪さがあります

ということで once のリスナで ready 判定だけして完了後に本来のリスナをつけます
最初は絶対 ready のイベントなのでチェックはスキップしてとりあえず resolve してます

const worker = new Worker("./worker.js", { type: "module" })

await new Promise(resolve => {
worker.addEventListener("message", (msg) => {
resolve()
}, { once: true })
})

worker.addEventListener("message", (msg) => {
const data = msg.data
// ...
})

worker.postMessage({ foo: "bar" })