diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 1364fa7f2..0fe09a26e 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 - run: npm ci diff --git a/.github/workflows/push-release.yml b/.github/workflows/push-release.yml index 4d11bae73..f78ce62fb 100644 --- a/.github/workflows/push-release.yml +++ b/.github/workflows/push-release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v5 with: ref: main - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 - run: npm ci diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml index 1e8f428eb..59e028acb 100644 --- a/.github/workflows/rollback.yml +++ b/.github/workflows/rollback.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 - run: npm ci diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45958296a..1162f1e83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 - run: npm ci diff --git a/README.md b/README.md index 64aa03a35..03f23845f 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.yml?branch=main&event=push)](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://github.com/MithrilJS/mithril.js/actions)   [![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/api/router.js b/api/router.js index bc4643f76..66c7fc792 100644 --- a/api/router.js +++ b/api/router.js @@ -1,26 +1,15 @@ "use strict" var Vnode = require("../render/vnode") -var m = require("../render/hyperscript") +var hyperscript = require("../render/hyperscript") +var decodeURIComponentSafe = require("../util/decodeURIComponentSafe") var buildPathname = require("../pathname/build") var parsePathname = require("../pathname/parse") var compileTemplate = require("../pathname/compileTemplate") var censor = require("../util/censor") -function decodeURIComponentSave(component) { - try { - return decodeURIComponent(component) - } catch(e) { - return component - } -} - module.exports = function($window, mountRedraw) { - var callAsync = $window == null - // In case Mithril.js' loaded globally without the DOM, let's not break - ? null - : typeof $window.setImmediate === "function" ? $window.setImmediate : $window.setTimeout var p = Promise.resolve() var scheduled = false @@ -63,12 +52,7 @@ module.exports = function($window, mountRedraw) { if (prefix[0] !== "/") prefix = "/" + prefix } } - // This seemingly useless `.concat()` speeds up the tests quite a bit, - // since the representation is consistently a relatively poorly - // optimized cons string. - var path = prefix.concat() - .replace(/(?:%[a-f89][a-f0-9])+/gim, decodeURIComponentSave) - .slice(route.prefix.length) + var path = decodeURIComponentSafe(prefix).slice(route.prefix.length) var data = parsePathname(path) Object.assign(data.params, $window.history.state) @@ -126,7 +110,7 @@ module.exports = function($window, mountRedraw) { // TODO: just do `mountRedraw.redraw()` here and elide the timer // dependency. Note that this will muck with tests a *lot*, so it's // not as easy of a change as it sounds. - callAsync(resolveRoute) + setTimeout(resolveRoute) } } @@ -189,7 +173,7 @@ module.exports = function($window, mountRedraw) { // // We don't strip the other parameters because for convenience we // let them be specified in the selector as well. - var child = m( + var child = hyperscript( vnode.attrs.selector || "a", censor(vnode.attrs, ["options", "params", "selector", "onclick"]), vnode.children diff --git a/api/tests/test-routerGetSet.js b/api/tests/test-routerGetSet.js index 51752c80b..9846ae052 100644 --- a/api/tests/test-routerGetSet.js +++ b/api/tests/test-routerGetSet.js @@ -220,7 +220,7 @@ o.spec("route.get/route.set", function() { route.set("/other/:a/:b", {a: "x", b: "y/z", c: "d", e: "f"}) setTimeout(function() { // Yep, before even the throttle mechanism takes hold. - o(route.get()).equals("/other/x/y%2Fz?c=d&e=f") + o(route.get()).equals("/other/x/y/z?c=d&e=f") throttleMock.fire() done() }) diff --git a/index.js b/index.js index b6ca3406a..66aab484c 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,9 @@ "use strict" var hyperscript = require("./hyperscript") -var request = require("./request") var mountRedraw = require("./mount-redraw") -var domFor = require("./render/domFor") +var request = require("./request") +var router = require("./route") var m = function m() { return hyperscript.apply(this, arguments) } m.m = hyperscript @@ -11,7 +11,7 @@ m.trust = hyperscript.trust m.fragment = hyperscript.fragment m.Fragment = "[" m.mount = mountRedraw.mount -m.route = require("./route") +m.route = router m.render = require("./render") m.redraw = mountRedraw.redraw m.request = request.request @@ -21,6 +21,6 @@ m.parsePathname = require("./pathname/parse") m.buildPathname = require("./pathname/build") m.vnode = require("./render/vnode") m.censor = require("./util/censor") -m.domFor = domFor.domFor +m.domFor = require("./render/domFor") module.exports = m diff --git a/querystring/parse.js b/querystring/parse.js index 1f2300a28..853acf123 100644 --- a/querystring/parse.js +++ b/querystring/parse.js @@ -1,12 +1,6 @@ "use strict" -function decodeURIComponentSave(str) { - try { - return decodeURIComponent(str) - } catch(err) { - return str - } -} +var decodeURIComponentSafe = require("../util/decodeURIComponentSafe") module.exports = function(string) { if (string === "" || string == null) return {} @@ -15,8 +9,8 @@ module.exports = function(string) { var entries = string.split("&"), counters = {}, data = {} for (var i = 0; i < entries.length; i++) { var entry = entries[i].split("=") - var key = decodeURIComponentSave(entry[0]) - var value = entry.length === 2 ? decodeURIComponentSave(entry[1]) : "" + var key = decodeURIComponentSafe(entry[0]) + var value = entry.length === 2 ? decodeURIComponentSafe(entry[1]) : "" if (value === "true") value = true else if (value === "false") value = false diff --git a/querystring/tests/test-parseQueryString.js b/querystring/tests/test-parseQueryString.js index 8d497cdb6..0e7fd4faf 100644 --- a/querystring/tests/test-parseQueryString.js +++ b/querystring/tests/test-parseQueryString.js @@ -22,7 +22,8 @@ o.spec("parseQueryString", function() { }) o("handles wrongly escaped values", function() { var data = parseQueryString("?test=%c5%a1%e8ZM%80%82H") - o(data).deepEquals({test: "%c5%a1%e8ZM%80%82H"}) + // decodes "%c5%a1" only + o(data).deepEquals({test: "ลก%e8ZM%80%82H"}) }) o("handles escaped slashes followed by a number", function () { var data = parseQueryString("?hello=%2Fen%2F1") diff --git a/render.js b/render.js index 042d78125..ac7969787 100644 --- a/render.js +++ b/render.js @@ -1,3 +1,3 @@ "use strict" -module.exports = require("./render/render")(typeof window !== "undefined" ? window : null) +module.exports = require("./render/render")() diff --git a/render/delayedRemoval.js b/render/delayedRemoval.js new file mode 100644 index 000000000..3fca3c918 --- /dev/null +++ b/render/delayedRemoval.js @@ -0,0 +1,3 @@ +"use strict" + +module.exports = new WeakMap diff --git a/render/domFor.js b/render/domFor.js index 89a5dd966..86516dc31 100644 --- a/render/domFor.js +++ b/render/domFor.js @@ -1,6 +1,6 @@ "use strict" -var delayedRemoval = new WeakMap +var delayedRemoval = require("./delayedRemoval") function *domFor(vnode) { // To avoid unintended mangling of the internal bundler, @@ -21,7 +21,4 @@ function *domFor(vnode) { while (domSize) } -module.exports = { - delayedRemoval: delayedRemoval, - domFor: domFor, -} +module.exports = domFor diff --git a/render/render.js b/render/render.js index 1c97dfab1..e85c5bf6a 100644 --- a/render/render.js +++ b/render/render.js @@ -1,9 +1,8 @@ "use strict" -var Vnode = require("../render/vnode") -var df = require("../render/domFor") -var delayedRemoval = df.delayedRemoval -var domFor = df.domFor +var Vnode = require("./vnode") +var delayedRemoval = require("./delayedRemoval") +var domFor = require("./domFor") var cachedAttrsIsStaticMap = require("./cachedAttrsIsStaticMap") module.exports = function() { diff --git a/render/tests/test-domFor.js b/render/tests/test-domFor.js index dae83bd13..fe25a65be 100644 --- a/render/tests/test-domFor.js +++ b/render/tests/test-domFor.js @@ -4,10 +4,10 @@ const o = require("ospec") const callAsync = require("../../test-utils/callAsync") const components = require("../../test-utils/components") const domMock = require("../../test-utils/domMock") -const vdom = require("../render") -const m = require("../hyperscript") -const fragment = require("../fragment") -const domFor = require("../../render/domFor").domFor +const vdom = require("../../render/render") +const m = require("../../render/hyperscript") +const fragment = require("../../render/fragment") +const domFor = require("../../render/domFor") o.spec("domFor(vnode)", function() { let $window, root, render diff --git a/render/vnode.js b/render/vnode.js index 821b21bb2..3616c0fec 100644 --- a/render/vnode.js +++ b/render/vnode.js @@ -10,24 +10,23 @@ Vnode.normalize = function(node) { return Vnode("#", undefined, undefined, String(node), undefined, undefined) } Vnode.normalizeChildren = function(input) { - var children = [] - if (input.length) { - var isKeyed = input[0] != null && input[0].key != null - // Note: this is a *very* perf-sensitive check. - // Fun fact: merging the loop like this is somehow faster than splitting - // it, noticeably so. - for (var i = 1; i < input.length; i++) { - if ((input[i] != null && input[i].key != null) !== isKeyed) { - throw new TypeError( - isKeyed && (input[i] == null || typeof input[i] === "boolean") - ? "In fragments, vnodes must either all have keys or none have keys. You may wish to consider using an explicit keyed empty fragment, m.fragment({key: ...}), instead of a hole." - : "In fragments, vnodes must either all have keys or none have keys." - ) - } - } - for (var i = 0; i < input.length; i++) { - children[i] = Vnode.normalize(input[i]) - } + // Preallocate the array length (initially holey) and fill every index immediately in order. + // Benchmarking shows better performance on V8. + var children = new Array(input.length) + // Count the number of keyed normalized vnodes for consistency check. + // Note: this is a perf-sensitive check. + // Fun fact: merging the loop like this is somehow faster than splitting + // the check within updateNodes(), noticeably so. + var numKeyed = 0 + for (var i = 0; i < input.length; i++) { + children[i] = Vnode.normalize(input[i]) + if (children[i] !== null && children[i].key != null) numKeyed++ + } + if (numKeyed !== 0 && numKeyed !== input.length) { + throw new TypeError(children.includes(null) + ? "In fragments, vnodes must either all have keys or none have keys. You may wish to consider using an explicit keyed empty fragment, m.fragment({key: ...}), instead of a hole." + : "In fragments, vnodes must either all have keys or none have keys." + ) } return children } diff --git a/scripts/_bundler-impl.js b/scripts/_bundler-impl.js index 5f289aa92..1536fbc62 100644 --- a/scripts/_bundler-impl.js +++ b/scripts/_bundler-impl.js @@ -157,6 +157,14 @@ module.exports = async (input) => { return open + fixed + close }) + // fix regexp literals + // Note: This regexp, while it doesn't technically capture all cases a regexp could appear, should hopefully work for now. + const regexpLiteral = /([=({[](?:[\s\u2028\u2029]|\/\/.*?[\r\n\u2028\u2029]|\/\*[\s\S]*?\*\/)*)(\/(?:[^\\\/[\r\n\u2028\u2029]|\\[^\r\n\u2028\u2029]|\[(?:[^\]\\\r\n\u2028\u2029]|\\[^\r\n\u2028\u2029])*\])+\/[$\p{ID_Continue}]*)/ug + code = code.replace(regexpLiteral, (match, pre, literal) => { + const fixed = literal.replace(variables, (match) => match.replace(/\d+$/, "")) + return pre + fixed + }) + //fix props const props = new RegExp(`(\\.\\.)?((?:[^:]\\/\\/.*)?\\.\\s*)(${candidates})|([\\{,]\\s*)(${candidates})(\\s*:)`, "gm") code = code.replace(props, (match, dotdot, dot, a, pre, b, post) => { diff --git a/scripts/bundler.js b/scripts/bundler.js index 819fc0c98..19417b89f 100644 --- a/scripts/bundler.js +++ b/scripts/bundler.js @@ -42,8 +42,8 @@ async function build() { return } console.log("minifying...") - // Terser's "reduce_funcs" option seems to degrade performance. So, disable it. - const minified = await Terser.minify(original, {compress: {reduce_funcs: false}, mangle: true}) + // Terser's "conditionals" and "reduce_funcs" options seem to degrade performance. So, disable them. + const minified = await Terser.minify(original, {compress: {conditionals: false, reduce_funcs: false}, mangle: true}) if (minified.error) throw new Error(minified.error) await writeFile(params.output, minified.code, "utf-8") const originalSize = Buffer.byteLength(original, "utf-8") diff --git a/scripts/tests/test-bundler.js b/scripts/tests/test-bundler.js index 0c9949010..5ab88df01 100644 --- a/scripts/tests/test-bundler.js +++ b/scripts/tests/test-bundler.js @@ -285,6 +285,15 @@ o.spec("bundler", async () => { o(await bundle(p("a.js"))).equals(';(function() {\nvar b0 = "b b b \\\" b"\nvar b = function() {return b0}\n}());') }) + o("does not mess up regexp literals", async () => { + await setup({ + "a.js": 'var b = require("./b")\nvar c = require("./c")', + "b.js": "var b = /b/\nvar g = 0\nmodule.exports = function() {return b}", + "c.js": "var b =\n\t/ b \\/ \\/ [a-b]/g\nvar d = b/b\nmodule.exports = function() {return b}", + }) + + o(await bundle(p("a.js"))).equals(";(function() {\nvar b0 = /b/\nvar g = 0\nvar b = function() {return b0}\nvar b1 =\n\t/ b \\/ \\/ [a-b]/g\nvar d = b1/b1\nvar c = function() {return b1}\n}());") + }) o("does not mess up properties", async () => { await setup({ "a.js": 'var b = require("./b")', diff --git a/test-utils/callAsync.js b/test-utils/callAsync.js index 426964c99..2667dbf8f 100644 --- a/test-utils/callAsync.js +++ b/test-utils/callAsync.js @@ -1,3 +1,3 @@ "use strict" -module.exports = typeof setImmediate === "function" ? setImmediate : setTimeout +module.exports = setTimeout diff --git a/util/censor.js b/util/censor.js index f21ce0d0f..2fa2958c9 100644 --- a/util/censor.js +++ b/util/censor.js @@ -24,8 +24,7 @@ // ``` var hasOwn = require("./hasOwn") -// Words in RegExp literals are sometimes mangled incorrectly by the internal bundler, so use RegExp(). -var magic = new RegExp("^(?:key|oninit|oncreate|onbeforeupdate|onupdate|onbeforeremove|onremove)$") +var magic = /^(?:key|oninit|oncreate|onbeforeupdate|onupdate|onbeforeremove|onremove)$/ module.exports = function(attrs, extras) { var result = {} diff --git a/util/decodeURIComponentSafe.js b/util/decodeURIComponentSafe.js new file mode 100644 index 000000000..b0698a87b --- /dev/null +++ b/util/decodeURIComponentSafe.js @@ -0,0 +1,35 @@ +"use strict" + +/* +Percent encodings encode UTF-8 bytes, so this regexp needs to match that. +Here's how UTF-8 encodes stuff: +- `00-7F`: 1-byte, for U+0000-U+007F +- `C2-DF 80-BF`: 2-byte, for U+0080-U+07FF +- `E0-EF 80-BF 80-BF`: 3-byte, encodes U+0800-U+FFFF +- `F0-F4 80-BF 80-BF 80-BF`: 4-byte, encodes U+10000-U+10FFFF +In this, there's a number of invalid byte sequences: +- `80-BF`: Continuation byte, invalid as start +- `C0-C1 80-BF`: Overlong encoding for U+0000-U+007F +- `E0 80-9F 80-BF`: Overlong encoding for U+0080-U+07FF +- `ED A0-BF 80-BF`: Encoding for UTF-16 surrogate U+D800-U+DFFF +- `F0 80-8F 80-BF 80-BF`: Overlong encoding for U+0800-U+FFFF +- `F4 90-BF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode. +- `F5-FF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode. +So in reality, only the following sequences can encode are valid characters: +- 00-7F +- C2-DF 80-BF +- E0 A0-BF 80-BF +- E1-EC 80-BF 80-BF +- ED 80-9F 80-BF +- EE-EF 80-BF 80-BF +- F0 90-BF 80-BF 80-BF +- F1-F3 80-BF 80-BF 80-BF +- F4 80-8F 80-BF 80-BF + +The regexp just tries to match this as compactly as possible. +*/ +var validUtf8Encodings = /%(?:[0-7]|(?!c[01]|e0%[89]|ed%[ab]|f0%8|f4%[9ab])(?:c|d|(?:e|f[0-4]%[89ab])[\da-f]%[89ab])[\da-f]%[89ab])[\da-f]/gi + +module.exports = function(str) { + return String(str).replace(validUtf8Encodings, decodeURIComponent) +} diff --git a/util/tests/test-decodeURIComponentSafe.js b/util/tests/test-decodeURIComponentSafe.js new file mode 100644 index 000000000..c9749e359 --- /dev/null +++ b/util/tests/test-decodeURIComponentSafe.js @@ -0,0 +1,119 @@ +"use strict" + +var o = require("ospec") +var decodeURIComponentSafe = require("../../util/decodeURIComponentSafe") + +o.spec("decodeURIComponentSafe", function() { + o("non-string type (compared to decodeURIComponent)", function() { + o(decodeURIComponentSafe()).equals(decodeURIComponent()) + o(decodeURIComponentSafe(null)).equals(decodeURIComponent(null)) + o(decodeURIComponentSafe(0)).equals(decodeURIComponent(0)) + o(decodeURIComponentSafe(true)).equals(decodeURIComponent(true)) + o(decodeURIComponentSafe(false)).equals(decodeURIComponent(false)) + o(decodeURIComponentSafe({})).equals(decodeURIComponent({})) + o(decodeURIComponentSafe([])).equals(decodeURIComponent([])) + o(decodeURIComponentSafe(function(){})).equals(decodeURIComponent(function(){})) + }) + + o("non-percent-encoded string", function() { + o(decodeURIComponentSafe("")).equals("") + o(decodeURIComponentSafe("1")).equals("1") + o(decodeURIComponentSafe("abc")).equals("abc") + o(decodeURIComponentSafe("๐Ÿ˜ƒ")).equals("๐Ÿ˜ƒ") + }) + + o("percent-encoded ASCII", function() { + for (var i = 0; i < 128; i++) { + var char = String.fromCharCode(i) + var uenc = "%" + Number(i).toString(16).padStart(2, "0").toUpperCase() + var lenc = "%" + Number(i).toString(16).padStart(2, "0").toLowerCase() + var uout = decodeURIComponentSafe(uenc) + var lout = decodeURIComponentSafe(lenc) + o(char).equals(uout) + o(char).equals(lout) + } + }) + + o("all code points (without surrogates)", function() { + var ranges = [ + [0x0000, 0xD7FF], + /* [0xD800, 0xDFFF], */ + [0xE000, 0x10FFFF] + ] + for (var [lo, hi] of ranges) { + for (var cp = lo; cp <= hi; cp++) { + var char = String.fromCodePoint(cp) + // including ASCII characters not encoded by encodeURIComponent + var enc = encodeURIComponent(char) + var out = decodeURIComponentSafe(enc) + o(char).equals(out) + } + } + }) + + o("invalid byte sequences", function() { + // `80-BF`: Continuation byte, invalid as start + o(decodeURIComponentSafe("%7F")).notEquals("%7F") + o(decodeURIComponentSafe("%80")).equals("%80") + o(decodeURIComponentSafe("%BF")).equals("%BF") + + // `C0-C1 80-BF`: Overlong encoding for U+0000-U+007F + o(decodeURIComponentSafe("%C0%80")).equals("%C0%80") // U+0000 + o(decodeURIComponentSafe("%C1%BF")).equals("%C1%BF") // U+007F + o(decodeURIComponentSafe("%C2%80")).notEquals("%C2%80") // U+0080 + + // `E0 80-9F 80-BF`: Overlong encoding for U+0080-U+07FF + o(decodeURIComponentSafe("%DF%BF")).notEquals("%DF%BF") // U+07FF + o(decodeURIComponentSafe("%E0%80%80")).equals("%E0%80%80") // U+0000 + o(decodeURIComponentSafe("%E0%9F%BF")).equals("%E0%9F%BF") // U+07FF + o(decodeURIComponentSafe("%E0%A0%80")).notEquals("%E0%A0%80") // U+0800 + + // `ED A0-BF 80-BF`: Encoding for UTF-16 surrogate U+D800-U+DFFF + o(decodeURIComponentSafe("%ED%9F%BF")).notEquals("%ED%9F%BF") // U+D7FF + o(decodeURIComponentSafe("%ED%A0%80")).equals("%ED%A0%80") // U+D800 + o(decodeURIComponentSafe("%ED%AF%BF")).equals("%ED%AF%BF") // U+DBFF + o(decodeURIComponentSafe("%ED%B0%80")).equals("%ED%B0%80") // U+DC00 + o(decodeURIComponentSafe("%ED%BF%BF")).equals("%ED%BF%BF") // U+DFFF + o(decodeURIComponentSafe("%EE%80%80")).notEquals("%EE%80%80") // U+E000 + + // `F0 80-8F 80-BF 80-BF`: Overlong encoding for U+0800-U+FFFF + o(decodeURIComponentSafe("%EF%BF%BF")).notEquals("%EF%BF%BF") // U+FFFF + o(decodeURIComponentSafe("%F0%80%80%80")).equals("%F0%80%80%80") // U+0000 + o(decodeURIComponentSafe("%E0%80%9F%BF")).equals("%E0%80%9F%BF") // U+07FF + o(decodeURIComponentSafe("%E0%80%A0%80")).equals("%E0%80%A0%80") // U+0800 + o(decodeURIComponentSafe("%F0%8F%BF%BF")).equals("%F0%8F%BF%BF") // U+FFFF + o(decodeURIComponentSafe("%F0%90%80%80")).notEquals("%F0%90%80%80") // U+10000 + + // `F4 90-BF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode. + o(decodeURIComponentSafe("%F4%8F%BF%BF")).notEquals("%F4%8F%BF%BF") // U+10FFFF + o(decodeURIComponentSafe("%F4%90%80%80")).equals("%F4%90%80%80") // U+110000 + o(decodeURIComponentSafe("%F4%BF%BF%BF")).equals("%F4%BF%BF%BF") // U+13FFFF + + // `F5-FF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode. + o(decodeURIComponentSafe("%F5")).equals("%F5") + o(decodeURIComponentSafe("%FF")).equals("%FF") + o(decodeURIComponentSafe("%F5%80%80%80")).equals("%F5%80%80%80") // U+140000 + o(decodeURIComponentSafe("%FF%8F%BF%BF")).equals("%FF%8F%BF%BF") + }) + + o("malformed URI sequence", function() { + // "%" only + o(() => decodeURIComponent("%")).throws(URIError) + o(decodeURIComponentSafe("%")).equals("%") + // "%" with one digit + o(() => decodeURIComponent("%1")).throws(URIError) + o(decodeURIComponentSafe("%1")).equals("%1") + // "%" with non-hexadecimal + o(() => decodeURIComponent("%G0")).throws(URIError) + o(decodeURIComponentSafe("%G0")).equals("%G0") + // "%" in string + o(() => decodeURIComponent("x%y")).throws(URIError) + o(decodeURIComponentSafe("x%y")).equals("x%y") + // Overlong encoding + o(() => decodeURIComponent("%E0%80%AF")).throws(URIError) + o(decodeURIComponentSafe("%E0%80%AF")).equals("%E0%80%AF") + // surrogate + o(() => decodeURIComponent("%ED%A0%80")).throws(URIError) + o(decodeURIComponentSafe("%ED%A0%80")).equals("%ED%A0%80") + }) +})