feat(minifier): improve constant evaluation (#8252)

This commit is contained in:
Boshen 2025-01-05 12:41:57 +00:00
parent e84f267a39
commit 4d8a08d2ac
6 changed files with 149 additions and 157 deletions

View file

@ -1,3 +1,4 @@
use core::f64;
use std::{borrow::Cow, cmp::Ordering}; use std::{borrow::Cow, cmp::Ordering};
use num_bigint::BigInt; use num_bigint::BigInt;
@ -149,6 +150,9 @@ pub trait ConstantEvaluation<'a> {
UnaryOperator::Void => Some(f64::NAN), UnaryOperator::Void => Some(f64::NAN),
_ => None, _ => None,
}, },
Expression::SequenceExpression(s) => {
s.expressions.last().and_then(|e| self.eval_to_number(e))
}
expr => { expr => {
use crate::ToNumber; use crate::ToNumber;
expr.to_number() expr.to_number()
@ -247,39 +251,20 @@ pub trait ConstantEvaluation<'a> {
}; };
Some(ConstantValue::Number(val)) Some(ConstantValue::Number(val))
} }
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)] #[expect(clippy::cast_sign_loss)]
BinaryOperator::ShiftLeft BinaryOperator::ShiftLeft
| BinaryOperator::ShiftRight | BinaryOperator::ShiftRight
| BinaryOperator::ShiftRightZeroFill => { | BinaryOperator::ShiftRightZeroFill => {
let left_num = self.get_side_free_number_value(left); let left = self.get_side_free_number_value(left)?;
let right_num = self.get_side_free_number_value(right); let right = self.get_side_free_number_value(right)?;
if let (Some(left_val), Some(right_val)) = (left_num, right_num) { let left = left.to_int_32();
if left_val.fract() != 0.0 || right_val.fract() != 0.0 { let right = (right.to_int_32() as u32) & 31;
return None; Some(ConstantValue::Number(match operator {
} BinaryOperator::ShiftLeft => f64::from(left << right),
// only the lower 5 bits are used when shifting, so don't do anything BinaryOperator::ShiftRight => f64::from(left >> right),
// if the shift amount is outside [0,32) BinaryOperator::ShiftRightZeroFill => f64::from((left as u32) >> right),
if !(0.0..32.0).contains(&right_val) { _ => unreachable!(),
return None; }))
}
let right_val_int = right_val as u32;
let bits = left_val.to_int_32();
let result_val: f64 = match operator {
BinaryOperator::ShiftLeft => f64::from(bits.wrapping_shl(right_val_int)),
BinaryOperator::ShiftRight => f64::from(bits.wrapping_shr(right_val_int)),
BinaryOperator::ShiftRightZeroFill => {
// JavaScript always treats the result of >>> as unsigned.
// We must force Rust to do the same here.
let bits = bits as u32;
let res = bits.wrapping_shr(right_val_int);
f64::from(res)
}
_ => unreachable!(),
};
return Some(ConstantValue::Number(result_val));
}
None
} }
BinaryOperator::LessThan => { BinaryOperator::LessThan => {
self.is_less_than(left, right, true).map(|value| match value { self.is_less_than(left, right, true).map(|value| match value {
@ -401,52 +386,36 @@ pub trait ConstantEvaluation<'a> {
}; };
Some(ConstantValue::String(Cow::Borrowed(s))) Some(ConstantValue::String(Cow::Borrowed(s)))
} }
UnaryOperator::Void => { UnaryOperator::Void => (expr.argument.is_literal() || !expr.may_have_side_effects())
if (!expr.argument.is_number() || !expr.argument.is_number_0()) .then_some(ConstantValue::Undefined),
&& !expr.may_have_side_effects()
{
return Some(ConstantValue::Undefined);
}
None
}
UnaryOperator::LogicalNot => { UnaryOperator::LogicalNot => {
self.get_boolean_value(&expr.argument).map(|b| !b).map(ConstantValue::Boolean) self.get_boolean_value(&expr.argument).map(|b| !b).map(ConstantValue::Boolean)
} }
UnaryOperator::UnaryPlus => { UnaryOperator::UnaryPlus => {
self.eval_to_number(&expr.argument).map(ConstantValue::Number) self.eval_to_number(&expr.argument).map(ConstantValue::Number)
} }
UnaryOperator::UnaryNegation => { UnaryOperator::UnaryNegation => match ValueType::from(&expr.argument) {
let ty = ValueType::from(&expr.argument); ValueType::BigInt => {
match ty { self.eval_to_big_int(&expr.argument).map(|v| -v).map(ConstantValue::BigInt)
ValueType::BigInt => {
self.eval_to_big_int(&expr.argument).map(|v| -v).map(ConstantValue::BigInt)
}
ValueType::Number => self
.eval_to_number(&expr.argument)
.map(|v| if v.is_nan() { v } else { -v })
.map(ConstantValue::Number),
_ => None,
} }
} ValueType::Number => self
UnaryOperator::BitwiseNot => { .eval_to_number(&expr.argument)
let ty = ValueType::from(&expr.argument); .map(|v| if v.is_nan() { v } else { -v })
match ty { .map(ConstantValue::Number),
ValueType::BigInt => { ValueType::Undefined => Some(ConstantValue::Number(f64::NAN)),
self.eval_to_big_int(&expr.argument).map(|v| !v).map(ConstantValue::BigInt) ValueType::Null => Some(ConstantValue::Number(-0.0)),
} _ => None,
#[expect(clippy::cast_lossless)] },
ValueType::Number => self UnaryOperator::BitwiseNot => match ValueType::from(&expr.argument) {
.eval_to_number(&expr.argument) ValueType::BigInt => {
.map(|v| !v.to_int_32()) self.eval_to_big_int(&expr.argument).map(|v| !v).map(ConstantValue::BigInt)
.map(|v| v as f64)
.map(ConstantValue::Number),
ValueType::Undefined | ValueType::Null => Some(ConstantValue::Number(-1.0)),
ValueType::Boolean => self
.get_side_free_boolean_value(&expr.argument)
.map(|v| ConstantValue::Number(if v { -2.0 } else { -1.0 })),
_ => None,
} }
} #[expect(clippy::cast_lossless)]
_ => self
.eval_to_number(&expr.argument)
.map(|v| (!v.to_int_32()) as f64)
.map(ConstantValue::Number),
},
UnaryOperator::Delete => None, UnaryOperator::Delete => None,
} }
} }

View file

@ -93,6 +93,7 @@ impl<'a> From<&Expression<'a>> for ValueType {
Expression::SequenceExpression(e) => { Expression::SequenceExpression(e) => {
e.expressions.last().map_or(ValueType::Undetermined, Self::from) e.expressions.last().map_or(ValueType::Undetermined, Self::from)
} }
Expression::AssignmentExpression(e) => Self::from(&e.right),
_ => Self::Undetermined, _ => Self::Undetermined,
} }
} }
@ -115,8 +116,27 @@ impl<'a> From<&BinaryExpression<'a>> for ValueType {
} }
Self::Undetermined Self::Undetermined
} }
BinaryOperator::Instanceof => Self::Boolean, BinaryOperator::Subtraction
_ => Self::Undetermined, | BinaryOperator::Multiplication
| BinaryOperator::Division
| BinaryOperator::Remainder
| BinaryOperator::ShiftLeft
| BinaryOperator::BitwiseOR
| BinaryOperator::ShiftRight
| BinaryOperator::BitwiseXOR
| BinaryOperator::BitwiseAnd
| BinaryOperator::Exponential
| BinaryOperator::ShiftRightZeroFill => Self::Number,
BinaryOperator::Instanceof
| BinaryOperator::In
| BinaryOperator::Equality
| BinaryOperator::Inequality
| BinaryOperator::StrictEquality
| BinaryOperator::StrictInequality
| BinaryOperator::LessThan
| BinaryOperator::LessEqualThan
| BinaryOperator::GreaterThan
| BinaryOperator::GreaterEqualThan => Self::Boolean,
} }
} }
} }

View file

@ -51,7 +51,7 @@ impl<'a, 'b> PeepholeFoldConstants {
Self { changed: false } Self { changed: false }
} }
#[allow(clippy::float_cmp)] #[expect(clippy::float_cmp)]
fn try_fold_unary_expr(e: &UnaryExpression<'a>, ctx: Ctx<'a, 'b>) -> Option<Expression<'a>> { fn try_fold_unary_expr(e: &UnaryExpression<'a>, ctx: Ctx<'a, 'b>) -> Option<Expression<'a>> {
match e.operator { match e.operator {
// Do not fold `void 0` back to `undefined`. // Do not fold `void 0` back to `undefined`.
@ -217,7 +217,7 @@ impl<'a, 'b> PeepholeFoldConstants {
None None
} }
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn try_fold_binary_expr( fn try_fold_binary_expr(
e: &mut BinaryExpression<'a>, e: &mut BinaryExpression<'a>,
ctx: Ctx<'a, 'b>, ctx: Ctx<'a, 'b>,
@ -300,7 +300,7 @@ impl<'a, 'b> PeepholeFoldConstants {
} }
// https://github.com/evanw/esbuild/blob/v0.24.2/internal/js_ast/js_ast_helpers.go#L1128 // https://github.com/evanw/esbuild/blob/v0.24.2/internal/js_ast/js_ast_helpers.go#L1128
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
#[must_use] #[must_use]
fn approximate_printed_int_char_count(value: f64) -> usize { fn approximate_printed_int_char_count(value: f64) -> usize {
let mut count = if value.is_infinite() { let mut count = if value.is_infinite() {
@ -505,48 +505,37 @@ impl<'a, 'b> PeepholeFoldConstants {
) -> Option<bool> { ) -> Option<bool> {
let left = ValueType::from(left_expr); let left = ValueType::from(left_expr);
let right = ValueType::from(right_expr); let right = ValueType::from(right_expr);
if left != ValueType::Undetermined && right != ValueType::Undetermined { if !left.is_undetermined() && !right.is_undetermined() {
// Strict equality can only be true for values of the same type. // Strict equality can only be true for values of the same type.
if left != right { if left != right {
return Some(false); return Some(false);
} }
return match left { return match left {
ValueType::Number => { ValueType::Number => {
let left_number = ctx.get_side_free_number_value(left_expr); let lnum = ctx.get_side_free_number_value(left_expr)?;
let right_number = ctx.get_side_free_number_value(right_expr); let rnum = ctx.get_side_free_number_value(right_expr)?;
if lnum.is_nan() || rnum.is_nan() {
if let (Some(l_num), Some(r_num)) = (left_number, right_number) { return Some(false);
if l_num.is_nan() || r_num.is_nan() {
return Some(false);
}
return Some(l_num == r_num);
} }
Some(lnum == rnum)
None
} }
ValueType::String => { ValueType::String => {
let left_string = ctx.get_side_free_string_value(left_expr); let left = ctx.get_side_free_string_value(left_expr)?;
let right_string = ctx.get_side_free_string_value(right_expr); let right = ctx.get_side_free_string_value(right_expr)?;
if let (Some(left_string), Some(right_string)) = (left_string, right_string) { Some(left == right)
return Some(left_string == right_string);
}
None
} }
ValueType::Undefined | ValueType::Null => Some(true), ValueType::Undefined | ValueType::Null => Some(true),
ValueType::Boolean if right.is_boolean() => { ValueType::Boolean if right.is_boolean() => {
let left = ctx.get_boolean_value(left_expr); let left = ctx.get_boolean_value(left_expr)?;
let right = ctx.get_boolean_value(right_expr); let right = ctx.get_boolean_value(right_expr)?;
if let (Some(left_bool), Some(right_bool)) = (left, right) { Some(left == right)
return Some(left_bool == right_bool);
}
None
} }
// TODO ValueType::BigInt => {
ValueType::BigInt let left = ctx.get_side_free_bigint_value(left_expr)?;
| ValueType::Object let right = ctx.get_side_free_bigint_value(right_expr)?;
| ValueType::Boolean Some(left == right)
| ValueType::Undetermined => None, }
ValueType::Object | ValueType::Boolean | ValueType::Undetermined => None,
}; };
} }
@ -648,6 +637,11 @@ mod test {
test(source_text, source_text); test(source_text, source_text);
} }
#[test]
fn test_comparison() {
test("(1, 2) !== 2", "false");
}
#[test] #[test]
fn undefined_comparison1() { fn undefined_comparison1() {
test("undefined == undefined", "true"); test("undefined == undefined", "true");
@ -1103,31 +1097,28 @@ mod test {
} }
#[test] #[test]
fn unary_ops() { fn test_fold_unary() {
// TODO: need to port test_same("!foo()");
// These cases are handled by PeepholeRemoveDeadCode in closure-compiler. test_same("~foo()");
// test_same("!foo()"); test_same("-foo()");
// test_same("~foo()");
// test_same("-foo()");
// These cases are handled here.
test("a=!true", "a=false"); test("a=!true", "a=false");
test("a=!10", "a=false"); test("a=!10", "a=false");
test("a=!false", "a=true"); test("a=!false", "a=true");
test_same("a=!foo()"); test_same("a=!foo()");
// test("a=-0", "a=-0.0");
// test("a=-(0)", "a=-0.0"); test("a=-0", "a=-0");
test("a=-(0)", "a=-0");
test_same("a=-Infinity"); test_same("a=-Infinity");
test("a=-NaN", "a=NaN"); test("a=-NaN", "a=NaN");
test_same("a=-foo()"); test_same("a=-foo()");
test("a=~~0", "a=0"); test("-undefined", "NaN");
test("a=~~10", "a=10"); test("-null", "-0");
test("a=~-7", "a=6"); test("-NaN", "NaN");
test_same("a=~~foo()");
// test("a=+true", "a=1"); test("a=+true", "a=1");
test("a=+10", "a=10"); test("a=+10", "a=10");
// test("a=+false", "a=0"); test("a=+false", "a=0");
test_same("a=+foo()"); test_same("a=+foo()");
test_same("a=+f"); test_same("a=+f");
// test("a=+(f?true:false)", "a=+(f?1:0)"); // test("a=+(f?true:false)", "a=+(f?1:0)");
@ -1135,15 +1126,19 @@ mod test {
test("a=+Infinity", "a=Infinity"); test("a=+Infinity", "a=Infinity");
test("a=+NaN", "a=NaN"); test("a=+NaN", "a=NaN");
test("a=+-7", "a=-7"); test("a=+-7", "a=-7");
// test("a=+.5", "a=.5"); test("a=+.5", "a=.5");
test("a=~~0", "a=0");
test("a=~~10", "a=10");
test("a=~-7", "a=6");
test_same("a=~~foo()");
test("a=~0xffffffff", "a=0"); test("a=~0xffffffff", "a=0");
test("a=~~0xffffffff", "a=-1"); test("a=~~0xffffffff", "a=-1");
// test_same("a=~.5", PeepholeFoldConstants.FRACTIONAL_BITWISE_OPERAND); // test_same("a=~.5");
} }
#[test] #[test]
fn unary_with_big_int() { fn test_fold_unary_big_int() {
test("-(1n)", "-1n"); test("-(1n)", "-1n");
test("- -1n", "1n"); test("- -1n", "1n");
test("!1n", "false"); test("!1n", "false");
@ -1453,6 +1448,8 @@ mod test {
test("~null", "-1"); test("~null", "-1");
test("~false", "-1"); test("~false", "-1");
test("~true", "-2"); test("~true", "-2");
test("~'1'", "-2");
test("~'-1'", "0");
} }
#[test] #[test]
@ -1485,9 +1482,9 @@ mod test {
test("x = 0xffffffff << 0", "x=-1"); test("x = 0xffffffff << 0", "x=-1");
test("x = 0xffffffff << 4", "x=-16"); test("x = 0xffffffff << 4", "x=-16");
test("1 << 32", "1<<32"); test("1 << 32", "1");
test("1 << -1", "1<<-1"); test("1 << -1", "1<<-1");
test("1 >> 32", "1>>32"); test("1 >> 32", "1");
// Regression on #6161, ported from <https://github.com/tc39/test262/blob/05c45a4c430ab6fee3e0c7f0d47d8a30d8876a6d/test/language/expressions/unsigned-right-shift/S9.6_A2.2.js>. // Regression on #6161, ported from <https://github.com/tc39/test262/blob/05c45a4c430ab6fee3e0c7f0d47d8a30d8876a6d/test/language/expressions/unsigned-right-shift/S9.6_A2.2.js>.
test("-2147483647 >>> 0", "2147483649"); test("-2147483647 >>> 0", "2147483649");
@ -1535,6 +1532,8 @@ mod test {
// test("x = (p1 + (p2 + 'a')) + 'b'", "x = (p1 + (p2 + 'ab'))"); // test("x = (p1 + (p2 + 'a')) + 'b'", "x = (p1 + (p2 + 'ab'))");
// test("'a' + ('b' + p1) + 1", "'ab' + p1 + 1"); // test("'a' + ('b' + p1) + 1", "'ab' + p1 + 1");
// test("x = 'a' + ('b' + p1 + 'c')", "x = 'ab' + (p1 + 'c')"); // test("x = 'a' + ('b' + p1 + 'c')", "x = 'ab' + (p1 + 'c')");
test("void 0 + ''", "'undefined'");
test_same("x = 'a' + (4 + p1 + 'a')"); test_same("x = 'a' + (4 + p1 + 'a')");
test_same("x = p1 / 3 + 4"); test_same("x = p1 / 3 + 4");
test_same("foo() + 3 + 'a' + foo()"); test_same("foo() + 3 + 'a' + foo()");
@ -1618,15 +1617,21 @@ mod test {
} }
#[test] #[test]
fn test_fold_shift_right_zero_fill() { fn test_fold_shift_left() {
test("10 >>> 1", "5"); test("1 << 3", "8");
test_same("-1 >>> 0"); test("1.2345 << 0", "1");
test_same("1 << 24");
} }
#[test] #[test]
fn test_fold_shift_left() { fn test_fold_shift_right() {
test("1 << 3", "8"); test("2147483647 >> -32.1", "2147483647");
test_same("1 << 24"); }
#[test]
fn test_fold_shift_right_zero_fill() {
test("10 >>> 1", "5");
test_same("-1 >>> 0");
} }
#[test] #[test]

View file

@ -506,6 +506,9 @@ impl<'a> PeepholeMinimizeConditions {
true true
} }
// `a instanceof b === true` -> `a instanceof b`
// `a instanceof b === false` -> `!(a instanceof b)`
// ^^^^^^^^^^^^^^ `ValueType::from(&e.left).is_boolean()` is `true`.
fn try_minimize_binary( fn try_minimize_binary(
e: &mut BinaryExpression<'a>, e: &mut BinaryExpression<'a>,
ctx: &mut TraverseCtx<'a>, ctx: &mut TraverseCtx<'a>,

View file

@ -176,10 +176,11 @@ impl<'a> PeepholeReplaceKnownMethods {
string_lit: &StringLiteral<'a>, string_lit: &StringLiteral<'a>,
ctx: &mut TraverseCtx<'a>, ctx: &mut TraverseCtx<'a>,
) -> Option<Expression<'a>> { ) -> Option<Expression<'a>> {
let char_at_index = call_expr.arguments.first().and_then(|arg| match arg { let char_at_index = match call_expr.arguments.first() {
Argument::SpreadElement(_) => None, None => Some(0.0),
_ => Ctx(ctx).get_side_free_number_value(arg.to_expression()), Some(Argument::SpreadElement(_)) => None,
})?; Some(e) => Ctx(ctx).get_side_free_number_value(e.to_expression()),
}?;
let span = call_expr.span; let span = call_expr.span;
// TODO: if `result` is `None`, return `NaN` instead of skipping the optimization // TODO: if `result` is `None`, return `NaN` instead of skipping the optimization
let result = string_lit.value.as_str().char_code_at(Some(char_at_index))?; let result = string_lit.value.as_str().char_code_at(Some(char_at_index))?;
@ -226,26 +227,19 @@ impl<'a> PeepholeReplaceKnownMethods {
Some(ctx.ast.expression_string_literal(span, result, None)) Some(ctx.ast.expression_string_literal(span, result, None))
} }
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_lossless)]
fn try_fold_string_from_char_code( fn try_fold_string_from_char_code(
ce: &CallExpression<'a>, ce: &CallExpression<'a>,
ctx: &mut TraverseCtx<'a>, ctx: &mut TraverseCtx<'a>,
) -> Option<Expression<'a>> { ) -> Option<Expression<'a>> {
let ctx = Ctx(ctx);
let args = &ce.arguments; let args = &ce.arguments;
if args.iter().any(|arg| !matches!(arg, Argument::NumericLiteral(_))) {
return None;
}
let mut s = String::with_capacity(args.len()); let mut s = String::with_capacity(args.len());
for arg in args { for arg in args {
let Argument::NumericLiteral(lit) = arg else { unreachable!() }; let expr = arg.as_expression()?;
if lit.value.is_nan() || lit.value.is_infinite() { let v = ctx.get_side_free_number_value(expr)?;
return None; let v = v.to_int_32() as u16 as u32;
} let c = char::try_from(v).ok()?;
let v = lit.value.to_int_32();
if v >= 65535 {
return None;
}
let Ok(v) = u32::try_from(v) else { return None };
let Ok(c) = char::try_from(v) else { return None };
s.push(c); s.push(c);
} }
Some(ctx.ast.expression_string_literal(ce.span, s, None)) Some(ctx.ast.expression_string_literal(ce.span, s, None))
@ -508,6 +502,7 @@ mod test {
#[test] #[test]
fn test_fold_string_char_code_at() { fn test_fold_string_char_code_at() {
fold("x = 'abcde'.charCodeAt()", "x = 97");
fold("x = 'abcde'.charCodeAt(0)", "x = 97"); fold("x = 'abcde'.charCodeAt(0)", "x = 97");
fold("x = 'abcde'.charCodeAt(1)", "x = 98"); fold("x = 'abcde'.charCodeAt(1)", "x = 98");
fold("x = 'abcde'.charCodeAt(2)", "x = 99"); fold("x = 'abcde'.charCodeAt(2)", "x = 99");
@ -1099,17 +1094,17 @@ mod test {
test("String.fromCharCode(120)", "'x'"); test("String.fromCharCode(120)", "'x'");
test("String.fromCharCode(120, 121)", "'xy'"); test("String.fromCharCode(120, 121)", "'xy'");
test_same("String.fromCharCode(55358, 56768)"); test_same("String.fromCharCode(55358, 56768)");
test("String.fromCharCode(0x10000)", "String.fromCharCode(65536)"); test("String.fromCharCode(0x10000)", "'\\0'");
test("String.fromCharCode(0x10078, 0x10079)", "String.fromCharCode(0x10078, 0x10079)"); test("String.fromCharCode(0x10078, 0x10079)", "'xy'");
test("String.fromCharCode(0x1_0000_FFFF)", "String.fromCharCode(4295032831)"); test("String.fromCharCode(0x1_0000_FFFF)", "'\u{ffff}'");
test_same("String.fromCharCode(NaN)"); test("String.fromCharCode(NaN)", "'\\0'");
test_same("String.fromCharCode(-Infinity)"); test("String.fromCharCode(-Infinity)", "'\\0'");
test_same("String.fromCharCode(Infinity)"); test("String.fromCharCode(Infinity)", "'\\0'");
test_same("String.fromCharCode(null)"); test("String.fromCharCode(null)", "'\\0'");
test_same("String.fromCharCode(undefined)"); test("String.fromCharCode(undefined)", "'\\0'");
test_same("String.fromCharCode('123')"); test("String.fromCharCode('123')", "'{'");
test_same("String.fromCharCode(x)"); test_same("String.fromCharCode(x)");
test_same("String.fromCharCode('x')"); test("String.fromCharCode('x')", "'\\0'");
test_same("String.fromCharCode('0.5')"); test("String.fromCharCode('0.5')", "'\\0'");
} }
} }

View file

@ -15,7 +15,7 @@ Original | minified | minified | gzip | gzip | Fixture
1.01 MB | 460.34 kB | 458.89 kB | 126.86 kB | 126.71 kB | bundle.min.js 1.01 MB | 460.34 kB | 458.89 kB | 126.86 kB | 126.71 kB | bundle.min.js
1.25 MB | 652.70 kB | 646.76 kB | 163.53 kB | 163.73 kB | three.js 1.25 MB | 652.70 kB | 646.76 kB | 163.54 kB | 163.73 kB | three.js
2.14 MB | 726.21 kB | 724.14 kB | 180.20 kB | 181.07 kB | victory.js 2.14 MB | 726.21 kB | 724.14 kB | 180.20 kB | 181.07 kB | victory.js