fix: improve exclusive change handler errors

This commit is contained in:
dpiercey 2025-11-04 20:01:39 -07:00 committed by Dylan Piercey
parent 7d72ba8c2f
commit 0247892da8
33 changed files with 187 additions and 23 deletions

View File

@ -0,0 +1,5 @@
---
"@marko/runtime-tags": patch
---
Improve error messaging when exclusive change handlers are specified on native elements.

View File

@ -0,0 +1,8 @@
{
"vars": {
"props": {
"$_": "r",
"$init": "m"
}
}
}

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1,4 @@
// size: 73 (min) 77 (brotli)
(_._script("a1", ($scope) => _._attrs_script($scope, 0)),
_._resume("a0", function () {}),
init());

View File

@ -0,0 +1,17 @@
export const $template = "<input>";
export const $walks = /* get, over(1) */" b";
import * as _ from "@marko/runtime-tags/debug/dom";
const $setup__script = _._script("__tests__/template.marko_0", $scope => _._attrs_script($scope, "#input/0"));
export function $setup($scope) {
_._attrs($scope, "#input/0", {
type: "checkbox",
checkedValue: 1,
...{
checkedChange: $checkedChange
}
});
$setup__script($scope);
}
function $checkedChange() {}
_._resume("__tests__/template.marko_0/checkedChange", $checkedChange);
export default /* @__PURE__ */_._template("__tests__/template.marko", $template, $walks, $setup);

View File

@ -0,0 +1,13 @@
import * as _ from "@marko/runtime-tags/debug/html";
export default _._template("__tests__/template.marko", input => {
const $scope0_id = _._scope_id();
_._html(`<input${_._attrs({
type: "checkbox",
checkedValue: 1,
...{
checkedChange: _._resume(function () {}, "__tests__/template.marko_0/checkedChange")
}
}, "#input/0", $scope0_id, "input")}>${_._el_resume($scope0_id, "#input/0")}`);
_._script($scope0_id, "__tests__/template.marko_0");
_._scope($scope0_id, {}, "__tests__/template.marko", 0);
});

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
<input type="checkbox" checkedValue=1 ...{ checkedChange() {} }>

View File

@ -0,0 +1 @@
export const error_runtime = true;

View File

@ -0,0 +1,8 @@
{
"vars": {
"props": {
"$_": "r",
"$init": "m"
}
}
}

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1,4 @@
// size: 73 (min) 77 (brotli)
(_._script("a1", ($scope) => _._attrs_script($scope, 0)),
_._resume("a0", function () {}),
init());

View File

@ -0,0 +1,17 @@
export const $template = "<input>";
export const $walks = /* get, over(1) */" b";
import * as _ from "@marko/runtime-tags/debug/dom";
const $setup__script = _._script("__tests__/template.marko_0", $scope => _._attrs_script($scope, "#input/0"));
export function $setup($scope) {
_._attrs($scope, "#input/0", {
type: "checkbox",
...{
checkedValue: 1,
checkedChange: $checkedChange
}
});
$setup__script($scope);
}
function $checkedChange() {}
_._resume("__tests__/template.marko_0/checkedChange", $checkedChange);
export default /* @__PURE__ */_._template("__tests__/template.marko", $template, $walks, $setup);

View File

@ -0,0 +1,13 @@
import * as _ from "@marko/runtime-tags/debug/html";
export default _._template("__tests__/template.marko", input => {
const $scope0_id = _._scope_id();
_._html(`<input${_._attrs({
type: "checkbox",
...{
checkedValue: 1,
checkedChange: _._resume(function () {}, "__tests__/template.marko_0/checkedChange")
}
}, "#input/0", $scope0_id, "input")}>${_._el_resume($scope0_id, "#input/0")}`);
_._script($scope0_id, "__tests__/template.marko_0");
_._scope($scope0_id, {}, "__tests__/template.marko", 0);
});

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
The attributes checkedChange and checkedValue are mutually exclusive.

View File

@ -0,0 +1 @@
<input type="checkbox" ...{ checkedValue: 1, checkedChange() {} }>

View File

@ -0,0 +1 @@
export const error_runtime = true;

View File

@ -0,0 +1,5 @@
at packages/runtime-tags/src/__tests__/fixtures/error-mututally-exclusive-static-native-attrs/template.marko:1:2
> 1 | <input type="checkbox" checkedValue=1 checkedChange() {}>
| ^^^^^ The attributes checkedChange and checkedValue are mutually exclusive.
2 |

View File

@ -0,0 +1,5 @@
at packages/runtime-tags/src/__tests__/fixtures/error-mututally-exclusive-static-native-attrs/template.marko:1:2
> 1 | <input type="checkbox" checkedValue=1 checkedChange() {}>
| ^^^^^ The attributes checkedChange and checkedValue are mutually exclusive.
2 |

View File

@ -0,0 +1 @@
<input type="checkbox" checkedValue=1 checkedChange() {}>

View File

@ -0,0 +1 @@
export const error_compiler = true;

View File

@ -21,3 +21,55 @@ export function _assert_hoist(value: unknown) {
);
}
}
export function assertExclusiveAttrs(
attrs: Record<string, unknown> | undefined,
onError = throwErr,
) {
if (attrs) {
let exclusiveAttrs: undefined | string[];
if (attrs.checkedChange) {
(exclusiveAttrs ||= []).push("checkedChange");
}
if (attrs.checkedValue) {
(exclusiveAttrs ||= []).push("checkedValue");
if (attrs.checked) {
exclusiveAttrs.push("checked");
}
} else if (attrs.checkedValueChange) {
(exclusiveAttrs ||= []).push("checkedValueChange");
if (attrs.checked) {
exclusiveAttrs.push("checked");
}
}
if (attrs.valueChange) {
(exclusiveAttrs ||= []).push("valueChange");
}
if (exclusiveAttrs && exclusiveAttrs.length > 1) {
onError(
`The attributes ${joinWithAnd(exclusiveAttrs)} are mutually exclusive.`,
);
}
}
}
function throwErr(msg: string) {
throw new Error(msg);
}
function joinWithAnd(a: string[]) {
switch (a.length) {
case 0:
return "";
case 1:
return a[0];
case 2:
return `${a[0]} and ${a[1]}`;
default:
return `${a.slice(0, -1).join(", ")}, and ${a[a.length - 1]}`;
}
}

View File

@ -1,3 +1,4 @@
import { assertExclusiveAttrs } from "../common/errors";
import {
classValue,
getEventHandlerName,
@ -123,6 +124,10 @@ export function _attrs(
}
}
if (MARKO_DEBUG) {
assertExclusiveAttrs(nextAttrs);
}
attrsInternal(scope, nodeAccessor, nextAttrs);
}
@ -167,6 +172,10 @@ export function _attrs_partial(
if (!skip[key]) partial[key] = nextAttrs[key];
}
if (MARKO_DEBUG) {
assertExclusiveAttrs({ ...nextAttrs, ...skip });
}
attrsInternal(scope, nodeAccessor, partial);
}

View File

@ -1,3 +1,4 @@
import { assertExclusiveAttrs } from "../common/errors";
import {
classValue,
getEventHandlerName,
@ -172,6 +173,10 @@ export function _attrs(
let events: Record<string, unknown> | undefined;
switch (tagName) {
case "input":
if (MARKO_DEBUG) {
assertExclusiveAttrs(data);
}
if (data.checkedChange) {
result += _attr_input_checked(
scopeId,

View File

@ -7,6 +7,7 @@ import {
getTagDef,
} from "@marko/compiler/babel-utils";
import { assertExclusiveAttrs } from "../../../common/errors";
import { getEventHandlerName, isEventHandler } from "../../../common/helpers";
import { WalkCode } from "../../../common/types";
import evaluate from "../../util/evaluate";
@ -125,7 +126,9 @@ export default {
}
}
assertExclusiveControllableGroups(tag, seen);
assertExclusiveAttrs(seen, (msg) => {
throw tag.get("name").buildCodeFrameError(msg);
});
if (
node.var ||
@ -747,28 +750,6 @@ export default {
}),
} satisfies TemplateVisitor<t.MarkoTag>;
function assertExclusiveControllableGroups(
tag: t.NodePath<t.MarkoTag>,
attrs: Record<string, t.MarkoAttribute>,
) {
const exclusiveGroups = [
attrs.open || attrs.openChange,
attrs.checked || attrs.checkedChange,
attrs.checkedValue || attrs.checkedValueChange,
attrs.valueChange,
].filter(Boolean);
if (exclusiveGroups.length > 1) {
throw tag
.get("name")
.buildCodeFrameError(
`The attributes ${exclusiveGroups
.map((attr) => `"${attr.name}"`)
.join(", ")} are mutually exclusive.`,
);
}
}
type RelatedControllable = ReturnType<typeof getRelatedControllable>;
function getRelatedControllable(
tagName: string,