diff --git a/.github/workflows/notify-release.yml b/.github/workflows/notify-release.yml new file mode 100644 index 000000000..9a33844f8 --- /dev/null +++ b/.github/workflows/notify-release.yml @@ -0,0 +1,14 @@ +name: Notify release + +on: + push: + tags: ['v*'] + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Send workflow dispatch to docs + run: gh --repo MithrilJS/docs workflow run package-update.yml -f package=mithril + env: + GH_TOKEN: ${{ secrets.DOCS_UPDATE_TOKEN }} diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml new file mode 100644 index 000000000..3e4f89f13 --- /dev/null +++ b/.github/workflows/publish-prerelease.yml @@ -0,0 +1,27 @@ +name: Publish prerelease and update PR + +on: + workflow_call: + workflow_dispatch: + +jobs: + update-pr: + concurrency: prr:pre-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run build + - run: npx pr-release pr --verbose --target release --source main --compact --verbose --minimize-semver-change + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + # The following will publish a prerelease to npm + - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc + name: Setup NPM Auth + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: npx pr-release infer-prerelease --preid=next --target release --source main --verbose --publish --minimize-semver-change + name: Publish \ No newline at end of file diff --git a/README.md b/README.md index f7adb0ba9..7aa0dd4b9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm Version](https://img.shields.io/npm/v/mithril.svg)](https://www.npmjs.com/package/mithril)   [![License](https://img.shields.io/npm/l/mithril.svg)](https://github.com/MithrilJS/mithril.js/blob/main/LICENSE)   [![npm Downloads](https://img.shields.io/npm/dm/mithril.svg)](https://www.npmjs.com/package/mithril)   -[![Build Status](https://img.shields.io/github/actions/workflow/status/MithrilJS/mithril.js/.github%2Fworkflows%2Ftest-main-push.yml)](https://www.npmjs.com/package/mithril)   +[![Build Status](https://img.shields.io/github/actions/workflow/status/MithrilJS/mithril.js/.github%2Fworkflows%2Ftest.yml?branch=main&event=push)](https://www.npmjs.com/package/mithril)   [![Donate at OpenCollective](https://img.shields.io/opencollective/all/mithriljs.svg?colorB=brightgreen)](https://opencollective.com/mithriljs)   [![Zulip, join chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://mithril.zulipchat.com/) diff --git a/package-lock.json b/package-lock.json index 09dd39189..1e4a51883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1159,10 +1159,11 @@ "dev": true }, "node_modules/glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^4.0.1", @@ -3373,9 +3374,9 @@ "dev": true }, "glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", "dev": true, "requires": { "foreground-child": "^3.1.0", diff --git a/render/render.js b/render/render.js index 08f0fe91d..d9b0f9d76 100644 --- a/render/render.js +++ b/render/render.js @@ -626,6 +626,7 @@ module.exports = function() { if (typeof vnode.tag !== "string") { if (vnode.instance != null) onremove(vnode.instance) } else { + if (vnode.events != null) vnode.events._ = null var children = vnode.children if (Array.isArray(children)) { for (var i = 0; i < children.length; i++) { @@ -810,7 +811,15 @@ module.exports = function() { var result if (typeof handler === "function") result = handler.call(ev.currentTarget, ev) else if (typeof handler.handleEvent === "function") handler.handleEvent(ev) - if (this._ && ev.redraw !== false) (0, this._)() + var self = this + if (self._ != null) { + if (ev.redraw !== false) (0, self._)() + if (result != null && typeof result.then === "function") { + Promise.resolve(result).then(function () { + if (self._ != null && ev.redraw !== false) (0, self._)() + }) + } + } if (result === false) { ev.preventDefault() ev.stopPropagation() diff --git a/render/tests/.eslintrc.js b/render/tests/.eslintrc.js new file mode 100644 index 000000000..5d14e0acd --- /dev/null +++ b/render/tests/.eslintrc.js @@ -0,0 +1,16 @@ +"use strict" + +module.exports = { + "extends": "../../.eslintrc.js", + "env": { + "browser": null, + "node": true, + "es2022": true, + }, + "parserOptions": { + "ecmaVersion": 2022, + }, + "rules": { + "no-process-env": "off", + }, +}; diff --git a/render/tests/test-event.js b/render/tests/test-event.js index 5acc29f41..01bebec66 100644 --- a/render/tests/test-event.js +++ b/render/tests/test-event.js @@ -1,6 +1,7 @@ "use strict" var o = require("ospec") +var callAsync = require("../../test-utils/callAsync") var domMock = require("../../test-utils/domMock") var vdom = require("../../render/render") var m = require("../../render/hyperscript") @@ -384,4 +385,627 @@ o.spec("event", function() { o(replacementRedraw.this).equals(undefined) o(replacementRedraw.args.length).equals(0) }) + o("async function", function(done) { + var div = m("div", {onclick: async function () {}}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + o(redraw.callCount).equals(2) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + done() + }) + }) + o("async function (await Promise)", function(done) { + var thenCB + var div = m("div", {onclick: async function () { + await new Promise(function(resolve){thenCB = resolve}) + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve + thenCB() + callAsync(function() { + o(redraw.callCount).equals(2) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + done() + }) + }) + }) + o("async function (await thenable)", function(done) { + var thenCB + var div = m("div", {onclick: async function () { + await {then(resolve){thenCB = resolve}} + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve + thenCB() + callAsync(function() { + o(redraw.callCount).equals(2) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + done() + }) + }) + }) + o("return Promise", function(done) { + var thenCB + var div = m("div", {onclick: function () { + return new Promise(function(resolve){thenCB = resolve}) + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve + thenCB() + callAsync(function() { + o(redraw.callCount).equals(2) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + done() + }) + }) + }) + o("return thenable", function(done) { + var thenCB + var div = m("div", {onclick: function () { + return {then(resolve){thenCB = resolve}} + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve + thenCB() + callAsync(function() { + o(redraw.callCount).equals(2) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + done() + }) + }) + }) + o.spec("do not asynchronous redraw when returned Promise is rejected", function() { + var error + o.beforeEach(function(){ + error = console.error + }) + o.afterEach(function(){ + console.error = error + }) + o("async function (throw Error)", function(done) { + var consoleSpy = o.spy() + console.error = consoleSpy + + var div = m("div", {onclick: async function () {throw Error("error")}}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + // sync redraw + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // do not async redraw + o(redraw.callCount).equals(1) + + // called console.error + o(consoleSpy.callCount).equals(1) + done() + }) + }) + o("async function (await Promise, reject)", function(done) { + var consoleSpy = o.spy() + console.error = consoleSpy + + var rejectCB + var div = m("div", {onclick: async function () { + await new Promise(function(_, reject){rejectCB = reject}) + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + // sync redraw + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // reject + rejectCB("error") + callAsync(function() { + // do not async redraw + o(redraw.callCount).equals(1) + + // called console.error + o(consoleSpy.callCount).equals(1) + done() + }) + }) + }) + o("async function (await thenable, reject)", function(done) { + var consoleSpy = o.spy() + console.error = consoleSpy + + var rejectCB + var div = m("div", {onclick: async function () { + await {then(_, reject){rejectCB = reject}} + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + // sync redraw + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // reject + rejectCB("error") + callAsync(function() { + // do not async redraw + o(redraw.callCount).equals(1) + + // called console.error + o(consoleSpy.callCount).equals(1) + done() + }) + }) + }) + o("async function (await Promise, throw Error)", function(done) { + var consoleSpy = o.spy() + console.error = consoleSpy + + var thenCB + var div = m("div", {onclick: async function () { + await new Promise(function(resolve){thenCB = resolve}) + throw Error("error") + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + // sync redraw + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve (and throw Error) + thenCB() + callAsync(function() { + // do not async redraw + o(redraw.callCount).equals(1) + + // called console.error + o(consoleSpy.callCount).equals(1) + done() + }) + }) + }) + o("async function (await thenable, throw Error)", function(done) { + var consoleSpy = o.spy() + console.error = consoleSpy + + var thenCB + var div = m("div", {onclick: async function () { + await {then(resolve){thenCB = resolve}} + throw Error("error") + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + // sync redraw + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve (and throw Error) + thenCB() + callAsync(function() { + // do not async redraw + o(redraw.callCount).equals(1) + + // called console.error + o(consoleSpy.callCount).equals(1) + done() + }) + }) + }) + o("return Promise (reject)", function(done) { + var consoleSpy = o.spy() + console.error = consoleSpy + + var rejectCB + var div = m("div", {onclick: function () { + return new Promise(function(_, reject){rejectCB = reject}) + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + // sync redraw + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // reject + rejectCB("error") + callAsync(function() { + // do not async redraw + o(redraw.callCount).equals(1) + + // called console.error + o(consoleSpy.callCount).equals(1) + done() + }) + }) + }) + o("return thenable (reject)", function(done) { + var consoleSpy = o.spy() + console.error = consoleSpy + + var rejectCB + var div = m("div", {onclick: function () { + return {then(_, reject){rejectCB = reject}} + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + // sync redraw + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve + rejectCB("error") + callAsync(function() { + // do not async redraw + o(redraw.callCount).equals(1) + + // called console.error + o(consoleSpy.callCount).equals(1) + done() + }) + }) + }) + }) + o("async function (event.redraw = false)", function(done) { + var spy = o.spy() + var div = m("div", {onclick: async function (ev) { + // set event.redraw = false to prevent redraws + ev.redraw = false + spy() + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + + // event listener has not yet been called + o(spy.callCount).equals(0) + + div.dom.dispatchEvent(e) + + // event listener called but not redraw + o(spy.callCount).equals(1) + o(redraw.callCount).equals(0) + + callAsync(function() { + o(spy.callCount).equals(1) + o(redraw.callCount).equals(0) + + done() + }) + }) + o("async function (event.redraw = false, await Promise)", function(done) { + var thenCB + var spy = o.spy(function(resolve){thenCB = resolve}) + var div = m("div", {onclick: async function (ev) { + // set event.redraw = false to prevent redraws + ev.redraw = false + await new Promise(spy) + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + + // event listener has not yet been called + o(spy.callCount).equals(0) + o(thenCB).equals(undefined) + + div.dom.dispatchEvent(e) + + // event listener called but not redraw + o(spy.callCount).equals(1) + o(thenCB).notEquals(undefined) + o(redraw.callCount).equals(0) + + callAsync(function() { + // not resolved yet + o(spy.callCount).equals(1) + o(redraw.callCount).equals(0) + + // resolve + thenCB() + callAsync(function() { + o(spy.callCount).equals(1) + o(redraw.callCount).equals(0) + + done() + }) + }) + }) + o("async function (await Promise, event.redraw = false)", function(done) { + var thenCB + var div = m("div", {onclick: async function (ev) { + await new Promise(function(resolve){thenCB = resolve}) + // set event.redraw = false to prevent additional redraw + ev.redraw = false + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(redraw.callCount).equals(1) + + // resolve + thenCB() + callAsync(function() { + o(redraw.callCount).equals(1) + + done() + }) + }) + }) + o("async function (event.redraw = false, await Promise, event.redraw = true)", function(done) { + var thenCB + var spy = o.spy(function(resolve){thenCB = resolve}) + var div = m("div", {onclick: async function (ev) { + // set event.redraw = false to prevent sync redraw + ev.redraw = false + await new Promise(spy) + // set event.redraw = true to enable async redraw + ev.redraw = true + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + + // event listener has not yet been called + o(spy.callCount).equals(0) + o(thenCB).equals(undefined) + + div.dom.dispatchEvent(e) + + // event listener called but not redraw + o(spy.callCount).equals(1) + o(thenCB).notEquals(undefined) + o(redraw.callCount).equals(0) + callAsync(function() { + // not resolved yet + o(spy.callCount).equals(1) + o(redraw.callCount).equals(0) + + // resolve + thenCB() + callAsync(function() { + o(spy.callCount).equals(1) + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + done() + }) + }) + }) + o("async function (multiple await)", function(done) { + var thenCB1, thenCB2 + var div = m("div", {onclick: async function () { + await new Promise(function(resolve){thenCB1 = resolve}) + await new Promise(function(resolve){thenCB2 = resolve}) + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + div.dom.dispatchEvent(e) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + o(redraw.callCount).equals(1) + + // resolve 1 + thenCB1() + callAsync(function() { + o(redraw.callCount).equals(1) + + // resolve 2 + thenCB2() + callAsync(function() { + o(redraw.callCount).equals(2) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + done() + }) + }) + }) + }) + o("avoid sync redraw after removal", function() { + var spy = o.spy() + var div = m("div", {onclick: spy}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + // remove div + render(root, []) + + // event listener has not yet been called + o(spy.callCount).equals(0) + + div.dom.dispatchEvent(e) + + // event listener called but not redraw + o(spy.callCount).equals(1) + o(redraw.callCount).equals(0) + }) + o("avoid async redraw after removal", function(done) { + var thenCB + var spy = o.spy(function(resolve){thenCB = resolve}) + var div = m("div", {onclick: async function () { + await new Promise(spy) + }}) + var e = $window.document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + render(root, div) + + // event listener has not yet been called + o(spy.callCount).equals(0) + o(thenCB).equals(undefined) + + div.dom.dispatchEvent(e) + + // event listener called + o(spy.callCount).equals(1) + o(thenCB).notEquals(undefined) + + o(redraw.callCount).equals(1) + o(redraw.this).equals(undefined) + o(redraw.args.length).equals(0) + + callAsync(function() { + // not resolved yet + o(spy.callCount).equals(1) + o(redraw.callCount).equals(1) + + // remove div + render(root, []) + + // resolve + thenCB() + callAsync(function() { + o(spy.callCount).equals(1) + o(redraw.callCount).equals(1) + + done() + }) + }) + }) })