Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions crates/vize/tests/check_legacy_vue2_event_payload_cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#![cfg(feature = "legacy")]

use std::{
path::{Path, PathBuf},
process::Command,
};

use vize_carton::cstr;

#[test]
fn legacy_vue2_component_event_payloads_stay_variadic_when_unresolved() {
let Some(corsa_path) = resolve_test_corsa_path() else {
return;
};
let project_root = create_project("legacy-vue2-event-payload-arity");

let output = Command::new(env!("CARGO_BIN_EXE_vize"))
.current_dir(&project_root)
.env("CORSA_PATH", corsa_path)
.args([
"check",
"--tsconfig",
"tsconfig.json",
"--format",
"json",
"src/App.vue",
])
.output()
.unwrap();

let stdout = std::str::from_utf8(&output.stdout).unwrap();
let stderr = std::str::from_utf8(&output.stderr).unwrap();
assert_eq!(
output.status.code(),
Some(0),
"stdout:\n{stdout}\nstderr:\n{stderr}"
);
let json: serde_json::Value = serde_json::from_str(stdout).unwrap();
assert_eq!(
json["errorCount"], 0,
"stdout:\n{stdout}\nstderr:\n{stderr}"
);
for unexpected in ["TS2345", "Target signature provides too few arguments"] {
assert!(
!stdout.contains(unexpected),
"Vue 2 custom event handler arity should stay loose:\n{stdout}"
);
}

let _ = std::fs::remove_dir_all(&project_root);
}

fn create_project(name: &str) -> PathBuf {
let project_root = unique_case_dir(name);
let _ = std::fs::remove_dir_all(&project_root);
std::fs::create_dir_all(project_root.join("src/components")).unwrap();
write_test_vue2_stub(&project_root.join("node_modules")).unwrap();
write_test_vite_stub(&project_root.join("node_modules")).unwrap();
std::fs::write(
project_root.join("tsconfig.json"),
r#"{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
},
"include": ["src/**/*.vue"]
}"#,
)
.unwrap();
std::fs::write(
project_root.join("vize.config.json"),
r#"{ "typeChecker": { "legacyVue2": true } }"#,
)
.unwrap();
std::fs::write(
project_root.join("src/components/CalendarRange.vue"),
r#"<script lang="ts">
import { defineComponent } from "vue"

export default defineComponent({
emits: {
"range-change": null,
submit() {
return true
},
},
})
</script>

<template>
<button type="button" />
</template>
"#,
)
.unwrap();
std::fs::write(
project_root.join("src/App.vue"),
r#"<script lang="ts">
import { defineComponent } from "vue"
import CalendarRange from "./components/CalendarRange.vue"

export default defineComponent({
components: { CalendarRange },
setup() {
function updateRange(
calendarDate: { start: string; end: string },
highlightSelectedDate: () => void,
) {
void calendarDate
highlightSelectedDate()
}
function submitHooks(hooks: { before?: () => void; after?: () => void }) {
hooks.before?.()
}
return { updateRange, submitHooks }
},
})
</script>

<template>
<CalendarRange
@range-change="updateRange"
@submit="submitHooks"
/>
</template>
"#,
)
.unwrap();
project_root
}

fn unique_case_dir(name: &str) -> PathBuf {
workspace_root()
.join("target")
.join("vize-tests")
.join("tests")
.join(cstr!("{name}-{}", std::process::id()).as_str())
}

fn workspace_root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("workspace root should exist")
}

fn resolve_test_corsa_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("CORSA_PATH")
&& Path::new(&path).exists()
{
return Some(PathBuf::from(path));
}

let workspace_root = workspace_root();
[
workspace_root.join("node_modules/.bin/tsgo"),
workspace_root.join("examples/vite-musea/node_modules/.bin/tsgo"),
]
.into_iter()
.find(|candidate| candidate.exists())
}

fn write_test_vue2_stub(target: &Path) -> std::io::Result<()> {
let vue_types_dir = target.join("vue").join("types");
std::fs::create_dir_all(&vue_types_dir)?;
std::fs::write(
target.join("vue").join("package.json"),
r#"{ "name": "vue", "types": "types/index.d.ts" }"#,
)?;
std::fs::write(
vue_types_dir.join("index.d.ts"),
r#"export interface Vue {
$attrs: Record<string, unknown>;
$refs: Record<string, any>;
$slots: Record<string, unknown>;
$emit: (...args: any[]) => void;
}
export declare function defineComponent<T>(options: T): T;
export default { version: '2.7.16' };
"#,
)?;
Ok(())
}

fn write_test_vite_stub(target: &Path) -> std::io::Result<()> {
let vite_dir = target.join("vite");
std::fs::create_dir_all(&vite_dir)?;
std::fs::write(
vite_dir.join("package.json"),
r#"{ "name": "vite", "types": "client.d.ts" }"#,
)?;
std::fs::write(vite_dir.join("client.d.ts"), "")?;
Ok(())
}
29 changes: 29 additions & 0 deletions crates/vize_canon/src/virtual_ts/legacy_vue2_vuetify_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,35 @@ function moveToDetail(item: Manager) {
);
}

#[test]
fn legacy_vue2_unresolved_component_event_payloads_stay_variadic() {
let script = r#"import CalendarRange from './CalendarRange.vue'
function updateRange(
calendarDate: { start: string; end: string },
highlightSelectedDate: () => void,
) {
void calendarDate
highlightSelectedDate()
}
function submitHooks(hooks: { before?: () => void; after?: () => void }) {
hooks.before?.()
}
"#;
let template = r#"<CalendarRange @range-change="updateRange" @submit="submitHooks" />"#;

let legacy = legacy_virtual_ts(script, template, &VirtualTsOptions::default());
assert!(
legacy.contains("_range_change_legacy_args")
&& legacy.contains("_submit_legacy_args")
&& legacy.contains("extends [] ? any[] : unknown[] extends"),
"legacy Vue 2 unresolved/empty component event args should become variadic any[]:\n{legacy}"
);
assert!(
!legacy.contains(" ? (($event: "),
"legacy Vue 2 component listeners should not collapse unresolved events to one $event:\n{legacy}"
);
}

#[test]
fn legacy_vue2_skips_external_component_prop_checks() {
let script = "const width = 320\n";
Expand Down
9 changes: 4 additions & 5 deletions crates/vize_canon/src/virtual_ts/scope/closures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,17 +369,16 @@ fn generate_scope_node(
)
.expect("component event handler should have a target component");
let event_type = event_types.event_type;
let args_type = event_types.args_type;
let listener_args_type = event_types.listener_args_type;
let listener_type = event_types.listener_type;
let listener_type_expr = event_types.listener_type_expr;
// Type the listener against the FULL emit argument tuple so
// multi-arg emits keep every parameter (#1512). When the emit
// signature stays unresolved (`unknown[]`, e.g. a fallthrough
// DOM event on a component), fall back to the single `$event`
// parameter so those handlers keep type-checking.
// DOM event on a component), standard mode falls back to one
// `$event`; legacy Vue 2 keeps custom event args variadic.
append!(
*ts,
"{indent}type {listener_type} = unknown[] extends {args_type} ? (($event: {event_type}) => unknown) : ((...args: {listener_args_type}) => unknown);\n",
"{indent}type {listener_type} = {listener_type_expr};\n",
);
// Receive every listener argument via a rest parameter typed by
// `Parameters<listener>` (always a tuple, so the spread targets a
Expand Down
21 changes: 16 additions & 5 deletions crates/vize_canon/src/virtual_ts/scope/component_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ use crate::virtual_ts::{

pub(super) struct ComponentEventTypes {
pub(super) event_type: String,
pub(super) args_type: String,
pub(super) listener_args_type: String,
pub(super) listener_type: String,
pub(super) listener_type_expr: String,
}

pub(super) fn generate_component_event_types(
Expand Down Expand Up @@ -119,11 +118,17 @@ pub(super) fn generate_component_event_types(
// wrapper so object payload callbacks stay permissive; otherwise they use
// the resolved emit argument tuple directly.
let listener_args_type = if legacy_vue2 {
let legacy_args_type =
cstr!("__{component_type_name}_{scope_id}_{safe_event_name}_legacy_args");
append!(
*ts,
"{indent}type {event_type} = {args_type} extends [] ? any : unknown[] extends {args_type} ? any : __VizeVue2LooseEventArg<{args_type}[0]>;\n",
);
cstr!("__VizeVue2LooseEmitArgs<{args_type}>")
append!(
*ts,
"{indent}type {legacy_args_type} = {args_type} extends [] ? any[] : unknown[] extends {args_type} ? any[] : __VizeVue2LooseEmitArgs<{args_type}>;\n",
);
legacy_args_type
} else {
let fallback_event = get_dom_event_type(data.event_name.as_str());
append!(
Expand All @@ -132,11 +137,17 @@ pub(super) fn generate_component_event_types(
);
args_type.clone()
};
let listener_type_expr = if legacy_vue2 {
cstr!("(...args: {listener_args_type}) => unknown")
} else {
cstr!(
"unknown[] extends {args_type} ? (($event: {event_type}) => unknown) : ((...args: {listener_args_type}) => unknown)"
)
};
Some(ComponentEventTypes {
event_type,
args_type,
listener_args_type,
listener_type,
listener_type_expr,
})
}

Expand Down
Loading