mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
feat(transformer_plugins): support typeof define keys (#23605)
This PR adds support for `define` so it does accept `typeof` expression keys in addition to what is supported at the moment. ## Why support `typeof` define keys? SSR frameworks need this behavior as they build the same code for different runtimes. Checks such as `typeof window === "undefined"` are used to choose between browser and server code. Replacing that expression at build time lets the bundler remove dead branches and their imports. Unlike defining `window` itself, it does not alter real uses such as `window.location` (which could cause bugs in prod). Supporting typeof keys in Oxc keeps the behavior in the AST-aware define transform and makes it available consistently to both Vite and Rolldown without framework-specific replacement plugins. ## Real World benefits/use cases - **TanStack Start / React Router:** Their shared hydration and router packages contain many `typeof window` and `typeof document` branches that Vite could eliminate per environment. - **Nuxt:** Uses `typeof window` and `typeof document` with webpack alredy. Its Vite build currently has to use the broader workaround of defining the globals themselves as `undefined`. - **Vinext:** Needs a custom AST transform to match Next.js, which folds `typeof window` in its compiler. - **General client-only deps:** Applications often guard DOM libraries with `typeof window !== "undefined"`. Folding the check lets server builds remove the guarded code and imports entirely.
This commit is contained in:
1 parent
53d083f080
commit
326fe25f3a
2 files changed
+290
-31
No files matched your search
@@ -16,6 +16,9 @@ use oxc_syntax::reference::Reference;
|
||||
|
||||
/// Configuration for [ReplaceGlobalDefines].
|
||||
///
|
||||
/// Define keys may be identifiers, property chains, `typeof` expressions over identifiers or
|
||||
/// property chains, and supported `import.meta` forms.
|
||||
///
|
||||
/// Due to the usage of an arena allocator, the constructor will parse once for grammatical errors,
|
||||
/// and does not save the constructed expression.
|
||||
///
|
||||
@@ -35,6 +38,7 @@ struct IdentifierDefine {
|
||||
struct ReplaceGlobalDefinesConfigImpl {
|
||||
identifier: IdentifierDefine,
|
||||
dot: Vec<DotDefine>,
|
||||
typeof_defines: Vec<TypeofDefine>,
|
||||
meta_property: Vec<MetaPropertyDefine>,
|
||||
/// extra field to avoid linear scan `meta_property` to check if it has `import.meta` every
|
||||
/// time
|
||||
@@ -43,6 +47,12 @@ struct ReplaceGlobalDefinesConfigImpl {
|
||||
import_meta: Option<CompactStr>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TypeofDefine {
|
||||
parts: Vec<CompactStr>,
|
||||
value: CompactStr,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DotDefine {
|
||||
/// Member expression parts
|
||||
@@ -73,6 +83,7 @@ impl DotDefine {
|
||||
enum IdentifierType {
|
||||
Identifier,
|
||||
DotDefines { parts: Vec<CompactStr> },
|
||||
Typeof { parts: Vec<CompactStr> },
|
||||
// import.meta.a
|
||||
ImportMetaWithParts { parts: Vec<CompactStr>, postfix_wildcard: bool },
|
||||
// import.meta or import.meta.*
|
||||
@@ -82,12 +93,13 @@ enum IdentifierType {
|
||||
impl ReplaceGlobalDefinesConfig {
|
||||
/// # Errors
|
||||
///
|
||||
/// * key is not an identifier
|
||||
/// * key is not a supported identifier, property chain, or `typeof` expression
|
||||
/// * value has a syntax error
|
||||
pub fn new<S: AsRef<str>>(defines: &[(S, S)]) -> Result<Self, Diagnostics> {
|
||||
let allocator = Allocator::default();
|
||||
let mut identifier_defines = vec![];
|
||||
let mut dot_defines = vec![];
|
||||
let mut typeof_defines = vec![];
|
||||
let mut meta_properties_defines = vec![];
|
||||
let mut import_meta = None;
|
||||
let mut has_this_expr_define = false;
|
||||
@@ -105,6 +117,9 @@ impl ReplaceGlobalDefinesConfig {
|
||||
IdentifierType::DotDefines { parts } => {
|
||||
dot_defines.push(DotDefine::new(parts, CompactStr::new(value)));
|
||||
}
|
||||
IdentifierType::Typeof { parts } => {
|
||||
typeof_defines.push(TypeofDefine { parts, value: CompactStr::new(value) });
|
||||
}
|
||||
IdentifierType::ImportMetaWithParts { parts, postfix_wildcard } => {
|
||||
meta_properties_defines.push(MetaPropertyDefine::new(
|
||||
parts,
|
||||
@@ -140,12 +155,19 @@ impl ReplaceGlobalDefinesConfig {
|
||||
Ok(Self(Arc::new(ReplaceGlobalDefinesConfigImpl {
|
||||
identifier: IdentifierDefine { identifier_defines, has_this_expr_define },
|
||||
dot: dot_defines,
|
||||
typeof_defines,
|
||||
meta_property: meta_properties_defines,
|
||||
import_meta,
|
||||
})))
|
||||
}
|
||||
|
||||
fn check_key(key: &str) -> Result<IdentifierType, Diagnostics> {
|
||||
if let Some(argument) = key.strip_prefix("typeof ") {
|
||||
let parts: Vec<&str> = argument.split('.').collect();
|
||||
let parts = Self::check_identifier_parts(key, &parts)?;
|
||||
return Ok(IdentifierType::Typeof { parts });
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = key.split('.').collect();
|
||||
|
||||
assert!(!parts.is_empty());
|
||||
@@ -164,24 +186,12 @@ impl ReplaceGlobalDefinesConfig {
|
||||
// We can ensure now the parts.len() >= 2
|
||||
let is_import_meta = parts[0] == "import" && parts[1] == "meta";
|
||||
|
||||
for part in &parts[0..normalized_parts_len] {
|
||||
if !is_identifier_name(part) {
|
||||
return Err(vec![OxcDiagnostic::error(format!(
|
||||
"The define key `{key}` contains an invalid identifier `{part}`."
|
||||
))]
|
||||
.into());
|
||||
}
|
||||
}
|
||||
let compact_parts = Self::check_identifier_parts(key, &parts[0..normalized_parts_len])?;
|
||||
if is_import_meta {
|
||||
match normalized_parts_len {
|
||||
2 => Ok(IdentifierType::ImportMeta(normalized_parts_len != parts.len())),
|
||||
_ => Ok(IdentifierType::ImportMetaWithParts {
|
||||
parts: parts
|
||||
.iter()
|
||||
.skip(2)
|
||||
.take(normalized_parts_len - 2)
|
||||
.map(|s| CompactStr::new(s))
|
||||
.collect(),
|
||||
parts: compact_parts.into_iter().skip(2).collect(),
|
||||
postfix_wildcard: normalized_parts_len != parts.len(),
|
||||
}),
|
||||
}
|
||||
@@ -192,16 +202,23 @@ impl ReplaceGlobalDefinesConfig {
|
||||
)]
|
||||
.into())
|
||||
} else {
|
||||
Ok(IdentifierType::DotDefines {
|
||||
parts: parts
|
||||
.iter()
|
||||
.take(normalized_parts_len)
|
||||
.map(|s| CompactStr::new(s))
|
||||
.collect(),
|
||||
})
|
||||
Ok(IdentifierType::DotDefines { parts: compact_parts })
|
||||
}
|
||||
}
|
||||
|
||||
fn check_identifier_parts(key: &str, parts: &[&str]) -> Result<Vec<CompactStr>, Diagnostics> {
|
||||
for part in parts {
|
||||
if !is_identifier_name(part) {
|
||||
return Err(vec![OxcDiagnostic::error(format!(
|
||||
"The define key `{key}` contains an invalid identifier `{part}`."
|
||||
))]
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parts.iter().map(|part| CompactStr::new(part)).collect())
|
||||
}
|
||||
|
||||
fn check_value(allocator: &Allocator, source_text: &str) -> Result<(), Diagnostics> {
|
||||
Parser::new(allocator, source_text, SourceType::default()).parse_expression()?;
|
||||
Ok(())
|
||||
@@ -237,6 +254,8 @@ pub struct ReplaceGlobalDefines<'a> {
|
||||
/// Depth of non-arrow functions we're inside of. Used to compute scope flags for `this`
|
||||
/// replacement.
|
||||
non_arrow_function_depth: u32,
|
||||
/// Depth of class field initializers or static blocks where `this` is the instance/class.
|
||||
class_this_depth: u32,
|
||||
/// Destructuring keys from the parent `VariableDeclarator` when its `id` is an
|
||||
/// `ObjectPattern`. Used to optimize object expression replacements by only keeping needed
|
||||
/// keys.
|
||||
@@ -248,7 +267,10 @@ impl<'a> VisitMut<'a> for ReplaceGlobalDefines<'a> {
|
||||
if self.ast_node_lock.is_some() {
|
||||
return;
|
||||
}
|
||||
let is_replaced = self.replace_identifier_defines(expr) || self.replace_dot_defines(expr);
|
||||
let is_replaced = (!self.config.0.typeof_defines.is_empty()
|
||||
&& self.replace_typeof_defines(expr))
|
||||
|| self.replace_identifier_defines(expr)
|
||||
|| self.replace_dot_defines(expr);
|
||||
// Clear `destructuring_keys` after checking the first expression so the
|
||||
// optimization only applies to the direct init expression of a
|
||||
// destructuring `VariableDeclarator`, not to nested sub-expressions
|
||||
@@ -294,6 +316,38 @@ impl<'a> VisitMut<'a> for ReplaceGlobalDefines<'a> {
|
||||
self.non_arrow_function_depth -= 1;
|
||||
}
|
||||
|
||||
fn visit_property_definition(&mut self, property: &mut PropertyDefinition<'a>) {
|
||||
self.visit_decorators(&mut property.decorators);
|
||||
self.visit_property_key(&mut property.key);
|
||||
if let Some(type_annotation) = &mut property.type_annotation {
|
||||
self.visit_ts_type_annotation(type_annotation);
|
||||
}
|
||||
if let Some(value) = &mut property.value {
|
||||
self.class_this_depth += 1;
|
||||
self.visit_expression(value);
|
||||
self.class_this_depth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_static_block(&mut self, block: &mut StaticBlock<'a>) {
|
||||
self.class_this_depth += 1;
|
||||
walk_mut::walk_static_block(self, block);
|
||||
self.class_this_depth -= 1;
|
||||
}
|
||||
|
||||
fn visit_accessor_property(&mut self, property: &mut AccessorProperty<'a>) {
|
||||
self.visit_decorators(&mut property.decorators);
|
||||
self.visit_property_key(&mut property.key);
|
||||
if let Some(type_annotation) = &mut property.type_annotation {
|
||||
self.visit_ts_type_annotation(type_annotation);
|
||||
}
|
||||
if let Some(value) = &mut property.value {
|
||||
self.class_this_depth += 1;
|
||||
self.visit_expression(value);
|
||||
self.class_this_depth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_variable_declarator(&mut self, declarator: &mut VariableDeclarator<'a>) {
|
||||
// Collect destructuring keys if LHS is an ObjectPattern.
|
||||
// `visit_expression` clears `destructuring_keys` after the first expression check,
|
||||
@@ -326,6 +380,7 @@ impl<'a> ReplaceGlobalDefines<'a> {
|
||||
changed: false,
|
||||
scoping: None,
|
||||
non_arrow_function_depth: 0,
|
||||
class_this_depth: 0,
|
||||
destructuring_keys: None,
|
||||
}
|
||||
}
|
||||
@@ -395,6 +450,83 @@ impl<'a> ReplaceGlobalDefines<'a> {
|
||||
false
|
||||
}
|
||||
|
||||
fn replace_typeof_defines(&mut self, expr: &mut Expression<'a>) -> bool {
|
||||
let Expression::UnaryExpression(unary) = expr else {
|
||||
return false;
|
||||
};
|
||||
if unary.operator != UnaryOperator::Typeof {
|
||||
return false;
|
||||
}
|
||||
|
||||
let scoping = self.scoping();
|
||||
let scope_flags = self.current_scope_flags();
|
||||
let value = self
|
||||
.config
|
||||
.0
|
||||
.typeof_defines
|
||||
.iter()
|
||||
.find(|define| Self::is_typeof_define(scoping, scope_flags, define, &unary.argument))
|
||||
.map(|define| define.value.clone());
|
||||
if let Some(value) = value {
|
||||
*expr = self.parse_value(&value);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_typeof_define(
|
||||
scoping: &Scoping,
|
||||
scope_flags: ScopeFlags,
|
||||
define: &TypeofDefine,
|
||||
argument: &Expression<'a>,
|
||||
) -> bool {
|
||||
let argument = argument.without_parentheses();
|
||||
if define.parts.len() == 1 {
|
||||
return match argument {
|
||||
Expression::Identifier(ident) => {
|
||||
ident.name == define.parts[0].as_str()
|
||||
&& Self::is_global_or_ambient_reference(scoping, ident)
|
||||
}
|
||||
Expression::ThisExpression(_) => {
|
||||
define.parts[0].as_str() == "this" && should_replace_this_expr(scope_flags)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
let member = match argument {
|
||||
Expression::StaticMemberExpression(member) => {
|
||||
DotDefineMemberExpression::StaticMemberExpression(member)
|
||||
}
|
||||
Expression::ComputedMemberExpression(member) => {
|
||||
DotDefineMemberExpression::ComputedMemberExpression(member)
|
||||
}
|
||||
Expression::ChainExpression(chain) => {
|
||||
let Some(member) = chain.expression.as_member_expression() else {
|
||||
return false;
|
||||
};
|
||||
match member {
|
||||
MemberExpression::StaticMemberExpression(member) => {
|
||||
DotDefineMemberExpression::StaticMemberExpression(member)
|
||||
}
|
||||
MemberExpression::ComputedMemberExpression(member) => {
|
||||
DotDefineMemberExpression::ComputedMemberExpression(member)
|
||||
}
|
||||
MemberExpression::PrivateFieldExpression(_) => return false,
|
||||
}
|
||||
}
|
||||
Expression::MetaProperty(meta) => {
|
||||
return define.parts.len() == 2
|
||||
&& define.parts[0].as_str() == "import"
|
||||
&& define.parts[1].as_str() == "meta"
|
||||
&& meta.meta.name == "import"
|
||||
&& meta.property.name == "meta";
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
Self::is_dot_define_parts(scoping, scope_flags, &define.parts, member)
|
||||
}
|
||||
|
||||
fn replace_identifier_define_impl(
|
||||
&mut self,
|
||||
ident: &ArenaBox<'_, IdentifierReference<'_>>,
|
||||
@@ -684,9 +816,15 @@ impl<'a> ReplaceGlobalDefines<'a> {
|
||||
false
|
||||
}
|
||||
|
||||
/// Compute the current scope flags based on function depth tracking.
|
||||
/// Compute the current scope flags based on function and class `this` depth tracking.
|
||||
fn current_scope_flags(&self) -> ScopeFlags {
|
||||
if self.non_arrow_function_depth > 0 { ScopeFlags::Function } else { ScopeFlags::Top }
|
||||
if self.non_arrow_function_depth > 0 {
|
||||
ScopeFlags::Function
|
||||
} else if self.class_this_depth > 0 {
|
||||
ScopeFlags::ClassStaticBlock
|
||||
} else {
|
||||
ScopeFlags::Top
|
||||
}
|
||||
}
|
||||
|
||||
/// If `expr` is a `ChainExpression` whose chain no longer contains any
|
||||
@@ -751,7 +889,16 @@ impl<'a> ReplaceGlobalDefines<'a> {
|
||||
dot_define: &DotDefine,
|
||||
member: DotDefineMemberExpression<'b, 'a>,
|
||||
) -> bool {
|
||||
debug_assert!(dot_define.parts.len() > 1);
|
||||
Self::is_dot_define_parts(scoping, scope_flags, &dot_define.parts, member)
|
||||
}
|
||||
|
||||
fn is_dot_define_parts<'b>(
|
||||
scoping: &Scoping,
|
||||
scope_flags: ScopeFlags,
|
||||
parts: &[CompactStr],
|
||||
member: DotDefineMemberExpression<'b, 'a>,
|
||||
) -> bool {
|
||||
debug_assert!(parts.len() > 1);
|
||||
let should_replace_this_expr = should_replace_this_expr(scope_flags);
|
||||
let Some(cur_part_name) = member.name() else {
|
||||
return false;
|
||||
@@ -759,7 +906,7 @@ impl<'a> ReplaceGlobalDefines<'a> {
|
||||
let mut cur_part_name: &str = cur_part_name.as_str();
|
||||
let mut current_part_member_expression = Some(member);
|
||||
|
||||
for (i, part) in dot_define.parts.iter().enumerate().rev() {
|
||||
for (i, part) in parts.iter().enumerate().rev() {
|
||||
if cur_part_name != part {
|
||||
return false;
|
||||
}
|
||||
@@ -768,7 +915,7 @@ impl<'a> ReplaceGlobalDefines<'a> {
|
||||
}
|
||||
|
||||
current_part_member_expression = if let Some(member) = current_part_member_expression {
|
||||
match &member.object() {
|
||||
match member.object().without_parentheses() {
|
||||
Expression::StaticMemberExpression(member) => {
|
||||
cur_part_name = &member.property.name;
|
||||
Some(DotDefineMemberExpression::StaticMemberExpression(member))
|
||||
@@ -799,8 +946,8 @@ impl<'a> ReplaceGlobalDefines<'a> {
|
||||
// We need the next two parts (going backwards) to be "meta" then "import"
|
||||
// i.e., parts[i-1] == "meta" and parts[i-2] == "import"
|
||||
if i >= 2
|
||||
&& dot_define.parts[i - 1].as_str() == "meta"
|
||||
&& dot_define.parts[i - 2].as_str() == "import"
|
||||
&& parts[i - 1].as_str() == "meta"
|
||||
&& parts[i - 2].as_str() == "import"
|
||||
{
|
||||
// Successfully matched import.meta at the expected position
|
||||
// Return true if we've consumed all parts (i == 2)
|
||||
@@ -919,7 +1066,8 @@ fn static_property_name_of_computed_expr<'b, 'a: 'b>(
|
||||
}
|
||||
|
||||
const fn should_replace_this_expr(scope_flags: ScopeFlags) -> bool {
|
||||
!scope_flags.contains(ScopeFlags::Function) || scope_flags.contains(ScopeFlags::Arrow)
|
||||
!scope_flags.contains(ScopeFlags::ClassStaticBlock)
|
||||
&& (!scope_flags.contains(ScopeFlags::Function) || scope_flags.contains(ScopeFlags::Arrow))
|
||||
}
|
||||
|
||||
fn assignment_target_from_expr(expr: Expression) -> Option<AssignmentTarget> {
|
||||
|
||||
@@ -94,6 +94,111 @@ fn shadowed() {
|
||||
test_same("(function (process) { foo(process.env.NODE_ENV) })()", &config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeof_define() {
|
||||
let config = config(&[
|
||||
("typeof window", "'undefined'"),
|
||||
("typeof process.env", "'object'"),
|
||||
("typeof import.meta", "'object'"),
|
||||
("typeof import.meta.env", "'object'"),
|
||||
]);
|
||||
|
||||
test_define_only(
|
||||
"foo(typeof window, typeof (window), typeof process.env, typeof (process).env, typeof process['env'], typeof (process)['env'], typeof process?.env, typeof import.meta, typeof import.meta.env)",
|
||||
"foo('undefined', 'undefined', 'object', 'object', 'object', 'object', 'object', 'object', 'object')",
|
||||
&config,
|
||||
);
|
||||
test("if (typeof window === 'undefined') server(); else client();", "server()", &config);
|
||||
test(
|
||||
"if (typeof window !== 'undefined') import('./client'); else import('./server');",
|
||||
"import('./server')",
|
||||
&config,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeof_define_is_exact() {
|
||||
let config = config(&[("typeof window", "'undefined'"), ("typeof process.env", "'object'")]);
|
||||
|
||||
test_same("foo(typeof window.document)", &config);
|
||||
test_same("foo(typeof process)", &config);
|
||||
test_same("foo(typeof process.env.NODE_ENV)", &config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeof_define_is_scope_aware() {
|
||||
let config =
|
||||
config(&[("typeof window", "'undefined'"), ("typeof window.document", "'object'")]);
|
||||
|
||||
test_define_only(
|
||||
"foo(typeof window, typeof window.document); function f(window) { foo(typeof window, typeof window.document) }",
|
||||
"foo('undefined', 'object'); function f(window) { foo(typeof window, typeof window.document) }",
|
||||
&config,
|
||||
);
|
||||
test_define_only(
|
||||
"const window = {}; foo(typeof window)",
|
||||
"const window = {}; foo(typeof window)",
|
||||
&config,
|
||||
);
|
||||
test_define_only("foo(typeof window); var window", "foo(typeof window); var window", &config);
|
||||
test_define_only(
|
||||
"try {} catch (window) { foo(typeof window) }",
|
||||
"try {} catch (window) { foo(typeof window) }",
|
||||
&config,
|
||||
);
|
||||
test_define_only(
|
||||
"import window from 'window'; foo(typeof window)",
|
||||
"import window from 'window'; foo(typeof window)",
|
||||
&config,
|
||||
);
|
||||
test_define_only(
|
||||
"declare const window: Window; foo(typeof window)",
|
||||
"declare const window: Window; foo('undefined')",
|
||||
&config,
|
||||
);
|
||||
test_define_only(
|
||||
"type WindowType = typeof window; foo(typeof window)",
|
||||
"type WindowType = typeof window; foo('undefined')",
|
||||
&config,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeof_define_this_expr() {
|
||||
let config = config(&[("typeof this", "'object'")]);
|
||||
|
||||
test_define_only("foo(typeof this)", "foo('object')", &config);
|
||||
test_define_only("(() => foo(typeof this))()", "(() => foo('object'))()", &config);
|
||||
test_define_only(
|
||||
"function f() { foo(typeof this); (() => foo(typeof this))() }",
|
||||
"function f() { foo(typeof this); (() => foo(typeof this))() }",
|
||||
&config,
|
||||
);
|
||||
test_define_only(
|
||||
"class C { field = typeof this; static field = typeof this; static { foo(typeof this) } }",
|
||||
"class C { field = typeof this; static field = typeof this; static { foo(typeof this) } }",
|
||||
&config,
|
||||
);
|
||||
test_define_only(
|
||||
"class C { [typeof this] = typeof this }",
|
||||
"class C { ['object'] = typeof this }",
|
||||
&config,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeof_define_takes_precedence_over_identifier_define() {
|
||||
let config = config(&[("window", "globalThis"), ("typeof window", "'undefined'")]);
|
||||
test_define_only("foo(window, typeof window)", "foo(globalThis, 'undefined')", &config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_typeof_define_key() {
|
||||
for key in ["typeof ", "typeof window", "typeof window.*", "typeof window[0]"] {
|
||||
assert!(ReplaceGlobalDefinesConfig::new(&[(key, "'undefined'")]).is_err(), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot() {
|
||||
let config = config(&[("process.env.NODE_ENV", "production")]);
|
||||
@@ -248,6 +353,12 @@ fn this_expr() {
|
||||
",
|
||||
&config,
|
||||
);
|
||||
|
||||
test_define_only(
|
||||
"class C { field = this; static field = this.foo; static { foo(this, this.foo) } }",
|
||||
"class C { field = this; static field = this.foo; static { foo(this, this.foo) } }",
|
||||
&config,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user