Skip to main content

Boa release v0.18

· 20 min read

Summary

Boa v0.18 is now available! After 7 months of development we are very happy to present you the latest release of the Boa JavaScript engine. Boa makes it easy to embed a JS engine in your projects, and you can even use it from WebAssembly. See the about page for more info.

In this release, our conformance has grown from 79.36% to 85.03% in the official ECMAScript Test Suite (Test262). This means we now pass 3,550 more tests than in the previous version. Moreover, our amount of ignored tests decreased from 9,496 to 1,391 thanks to all the new builtins we have implemented for this release.

You can check the full list of changes here, and the full information on conformance here.

You probably noticed that something seems different... This release marks a major update to the design of our website, and the introduction of our new logo! We'd like to thank @ZackMitkin for being the one that started the work on this nifty redesign, and @kelbazz for designing the logo. We're planning to add some additional pages to learn more about the APIs that Boa exposes. Additionally, expect some more blog posts from us in the future! We would like to write about how to use certain APIs, design challenges that we encountered while developing the engine, and internal implementation details. Subscribe to our RSS feed if you're interested in staying up to date!

This big release was partly possible thanks to those who have supported us. Thanks to funds we've received we have been able to renew our domain name, remunerate members of the team who have worked on the features released, and discuss the possibility of using dedicated servers for benchmarking. If you wish to sponsor Boa, you can do so by donating to our open collective. You can also check easy or good first issues if you want to contribute some code instead.

Highlights

We're on test262.fyi

Thanks to the amazing work of CanadaHonk, Boa is now listed on test262.fyi! This is a daily runner of the official test262 test suite that runs a nightly build of Boa, along with other engines, and updates the results every day. This is using the tip of the main branch of Boa alongside the latest test262 changes pushed to their main branch.

This is a great achievement for us and we are very proud to be listed alongside other major JavaScript engines. It should be much easier for users to compare Boa's conformance tests with other engines.

Temporal

A lot of work has been put over the past few months on the Temporal API. The Temporal API is a new set of built-in objects and functions that is designed to be a more modern replacement for the Date object, providing a more feature-rich and flexible API for working with dates and times.

It is currently a stage 3 proposal and we are working alongside the TC39 champions to put together a solid implementation. Since Temporal is such an extensive specification, we have done most of the work outside of Boa so that it can be used in other projects. This work can be found in the temporal_rs repository.

We hope to release a full blog post on Temporal in the future, but for now, let's look at a couple small examples of Temporal.

In JavaScript:

// JavaScript's Temporal built-in object.

// For example, you can customize you're own calendar implementations!
class CustomCalendar extends Temporal.Calendar {
constructor() {
super("iso8601");
}
inLeapYear(dateLike) {
messageInACalendar = "It's a message in a Calendar!";
return dateLike.daysInYear === 366;
}
}

let messageInACalendar;
// Construct the CustomCalendar.
const calendar = new CustomCalendar();

const boaReleaseDay = new Temporal.PlainDate(2024, 3, 7, calendar);
const leap = boaReleaseDay.inLeapYear;

messageInACalendar;
// Outputs: "It's a message in a Calendar!"

In Rust:

// Rust's `temporal_rs` crate
use temporal_rs::{components::{calendar::CalendarSlot, Date}, options::ArithmeticOverflow };
use std::str::FromStr;

// Create a Calendar slot value from a string
let calendar = CalendarSlot::<()>::from_str("iso8601").unwrap();

// Create a date. The date can be made to either reject or constrain the input.
let date = Date::<()>::new(2024, 3, 7, calendar, ArithmeticOverflow::Reject).unwrap();

assert_eq!(date.iso_year(), date.year().unwrap());

Please note that Temporal is still an experimental feature, and while a lot of progress has been made, there is still more work to be completed until it is production ready.

If you're interested in learning more or want to contribute to the native Rust implementation of Temporal, feel free to check out temporal_rs's issues!

RegExp

Over the past 7 months there has been some effort poured into an improved implementation of RegExp. This includes:

  • Support for RegExp.prototype.hasIndices (Thanks to @dirkdev98!).
  • Support for Unicode sets, aka the v flag.
  • Support for UTF-16 text searches.
  • General fixes around RegExp(), RegExp.toString() and RegExp.match().

Here is a table showing the progress of RegExp between v0.17 and v0.18:

Test262v0.17 (July 2023)v0.18 (Feb 2024)
Total1,9151,920
Pass1,0711,878
Fail1322
Skipped71240

That's a whopping 807 more tests passed!

We only have two failing tests left and both are caused by the lack of Unicode 15.1 support. The remaining skipped tests are all related to stage 3 proposals.

Shared Array Buffer + Atomics

The SharedArrayBuffer and Atomics builtins have been implemented in this release. This means embedders can now orchestrate Contexts running on separate threads to execute shared work between them.

The Atomics builtin object contains several static methods that allow executing atomic operations on shared memory. In addition to that, it also contains the wait() and notify() methods, which offers the same functionality as Linux futexes for JS's worker threads:

// On the main thread
const sab = new SharedArrayBuffer(1024);
const int32 = new Int32Array(sab);
send(worker1, int32);
send(worker2, int32);

// On worker1
int32 = receive();
Atomics.wait(int32, 0, 0);
console.log(int32[0]); // 123

// On worker2
int32 = receive();
console.log(int32[0]); // 0
Atomics.store(int32, 0, 123);
Atomics.notify(int32, 0, 1);

Intl updates

We're keeping the good progress on our Intl implementation, and now we have the Intl.PluralRules builtin and (a first prototype of) the Intl.NumberFormat builtin in place.

As mentioned by the Mozilla docs:

Languages use different patterns for expressing both plural numbers of items (cardinal numbers) and for expressing the order of items (ordinal numbers). English has two forms for expressing cardinal numbers: one for the singular "item" (1 hour, 1 dog, 1 fish) and the other for zero or any other number of "items" (0 hours, 2 lemmings, 100000.5 fish), while Chinese has only one form, and Arabic has six! Similarly, English has four forms for expressing ordinal numbers: "th", "st", "nd", "rd", giving the sequence: 0th, 1st, 2nd, 3rd, 4th, 5th, ..., 21st, 22nd, 23rd, 24th, 25th, and so on, while both Chinese and Arabic only have one form for ordinal numbers.

This variation between languages makes it really hard to properly localize a cardinal or ordinal number. To fix this, the CLDR (Common Locale Data Repository) project has been collecting information about the "plural category" of certain numeric patterns on many languages, and Intl.PluralRules objects are the builtin objects that enable obtaining this information in an easy way:

const pr = new Intl.PluralRules("en-US", { type: "ordinal" });

const suffixes = new Map([
["one", "st"],
["two", "nd"],
["few", "rd"],
["other", "th"],
]);

const getSuffix = (n) => {
return suffixes.get(pr.select(n));
};

console.log(getSuffix(0)); // "th"
console.log(getSuffix(1)); // "st"
console.log(getSuffix(2)); // "nd"
console.log(getSuffix(3)); // "rd"
console.log(getSuffix(4)); // "th"

console.log(getSuffix(21)); // "st"
console.log(getSuffix(42)); // "nd"
console.log(getSuffix(73)); // "th"

On the same vein, Intl.NumberFormat objects can format numbers in a language-sensitive way:

const nf = new Intl.NumberFormat("bn", {
useGrouping: "min2",
minimumSignificantDigits: 3,
maximumSignificantDigits: 7,
});

console.log(nf.format(10003.1234)); // ১০,০০৩.১২

However, we need to mention that Intl.NumberFormat is NOT feature complete at the moment, since it only allows formatting numbers in the standard notation with no currencies or units. We're still working on adding the missing features, but we hope that this initial prototype is at least useful for some use cases.

Builtins updates

While this new release is filled with shiny new features and APIs, it should be noted that the ECMAScript 262 specification is constantly evolving, which is why there are also a lot of small changes and additions to existing builtins that keep Boa updated to the latest revisions of the specification.

All examples were taken from the Mozilla Web Docs.

findLast and findLastIndex on TypedArray

function isPrime(element) {
if (element % 2 === 0 || element < 2) {
return false;
}
for (let factor = 3; factor <= Math.sqrt(element); factor += 2) {
if (element % factor === 0) {
return false;
}
}
return true;
}

let uint8 = new Uint8Array([4, 6, 8, 12]);
console.log(uint8.findLast(isPrime)); // undefined (no primes in array)
uint8 = new Uint8Array([4, 5, 7, 8, 9, 11, 12]);
console.log(uint8.findLast(isPrime)); // 11

String.prototype.isWellFormed and String.prototype.toWellFormed

const illFormed = "https://example.com/search?q=\uD800";

try {
encodeURI(illFormed);
} catch (e) {
console.log(e); // URIError: URI malformed
}

if (illFormed.isWellFormed()) {
console.log(encodeURI(illFormed));
} else {
console.warn("Ill-formed strings encountered."); // Ill-formed strings encountered.
}

Change Array by copy

const months = ["Mar", "Jan", "Feb", "Dec"];
const sortedMonths = months.toSorted();
console.log(sortedMonths); // ['Dec', 'Feb', 'Jan', 'Mar']
console.log(months); // ['Mar', 'Jan', 'Feb', 'Dec']

const values = [1, 10, 21, 2];
const sortedValues = values.toSorted((a, b) => a - b);
console.log(sortedValues); // [1, 2, 10, 21]
console.log(values); // [1, 10, 21, 2]

Grouping functions

const array = [1, 2, 3, 4, 5];

// `Object.groupBy` groups items by arbitrary key.
// In this case, we're grouping by even/odd keys
Object.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even" : "odd";
});
// => { odd: [1, 3, 5], even: [2, 4] }

// `Map.groupBy` returns items in a Map, and is useful for grouping
// using an object key.
const odd = { odd: true };
const even = { even: true };
Map.groupBy(array, (num, index) => {
return num % 2 === 0 ? even : odd;
});
// => Map { {odd: true}: [1, 3, 5], {even: true}: [2, 4] }

Resizable buffers

const buffer = new ArrayBuffer(8, { maxByteLength: 16 });

console.log(buffer.byteLength); // 8

buffer.resize(12);

console.log(buffer.byteLength); // 12

Transferrable buffers

const buffer = new ArrayBuffer(8, { maxByteLength: 16 });
const view = new Uint8Array(buffer);
view[1] = 2;
view[7] = 4;

// Copy the buffer to a smaller size
const buffer2 = buffer.transfer(4);
console.log(buffer2.byteLength); // 4
console.log(buffer2.maxByteLength); // 16
const view2 = new Uint8Array(buffer2);
console.log(view2[1]); // 2
console.log(view2[7]); // undefined
buffer2.resize(8);
console.log(view2[7]); // 0

// Copy the buffer to a larger size within maxByteLength
const buffer3 = buffer2.transfer(12);
console.log(buffer3.byteLength); // 12

// Copy the buffer to a larger size than maxByteLength
buffer3.transfer(20); // RangeError: Invalid array buffer length

APIs updates

Experimental features

Some of you might have noticed that the previous section contained a builtin addition that isn't technically a "spec addition", but a "proposal for a spec addition". To clarify, the ArrayBuffer.prototype.transfer and friends proposal is, at the time of the publication of this post, still at stage 3 on the TC39 Process. Generally, stages 3 and below need to be gated by implementors; this avoids exposing experimental APIs to users.

Mirroring this general idea, we introduced a new experimental feature for the boa_engine crate. Enabling this feature will make it possible to test future proposals for the ECMAScript specification using Boa, but we do not recommend enabling the feature in production environments.

We're still trying to find a way to enable experimental features in a more granular way, since the current flag allows enabling either all or no experimental features; definitely not ideal. So, expect some API changes in the future around this. But for now, have fun testing the new proposals!

[[HostDefined]] fields

In this version, we introduced a new API to attach custom data to realms, scripts and modules. The HostDefined struct is a more composable way of attaching custom data. Instead of attaching only a single type casted to an Any, you can insert many types to the HostDefined map, and every separate type will have its own value stored inside the map.

// Example snippet taken from https://github.com/boa-dev/boa/blob/main/examples/src/bin/host_defined.rs
// Check that file for a more extensive example.

/// Custom host-defined struct that has some state, and can be shared between JavaScript and rust.
#[derive(Default, Trace, Finalize, JsData)]
struct CustomHostDefinedStruct {
#[unsafe_ignore_trace]
counter: usize,
}

// We create a new `Context` to create a new Javascript executor.
let mut context = Context::default();

// Get the realm from the context.
let realm = context.realm().clone();

// Insert a default CustomHostDefinedStruct.
realm
.host_defined_mut()
.insert_default::<CustomHostDefinedStruct>();

assert!(realm.host_defined().has::<CustomHostDefinedStruct>());

// Get the [[HostDefined]] field from the realm and downcast it to our concrete type.
let host_defined = realm.host_defined();
let Some(host_defined) = host_defined.get::<CustomHostDefinedStruct>() else {
return Err(JsNativeError::typ()
.with_message("Realm does not have HostDefined field")
.into());
};

// Assert that the [[HostDefined]] field is in it's initial state.
assert_eq!(host_defined.counter, 0);

Class redesign + API enhancements

There were some small improvements made to our Class trait API, including a way to cache custom Class implementors into the Context itself for easy access to the constructor and prototype objects. This is part of an ongoing effort about improving the APIs around the Class trait.

// An example of what this new API allows
// Assume there's already a `Person` struct that implements `Class`.

let mut context = Context::default();
context
.register_global_class::<Person>()
.expect("the Person builtin shouldn't exist");

// Previously, the line below had to be done manually using something like
// let prototype = context
// .global_object()
// .get(js_string!("Person"), context)
// .unwrap()
// .as_object()
// .cloned()
// .unwrap()
// .get(js_string("prototype"), context)
// .unwrap()
// .as_object()
// .cloned()
// .unwrap();
// Yeah... it's a handful.
let prototype = context.get_global_class::<Person>().unwrap().prototype();

Runtime limits

We added new APIs to limit the execution of the engine at runtime! This new API has some limitations such as being unable to track limits inside native Rust functions, and we're still working on offering more options for other runtime limits such as heap size limits, but we hope this is at least useful for some users.

// Snippet taken from https://github.com/boa-dev/boa/blob/main/examples/src/bin/runtime_limits.rs
// Check that file for the full example.
// Create the JavaScript context.
let mut context = Context::default();

// Set the context's runtime limit on loops to 10 iterations.
context.runtime_limits_mut().set_loop_iteration_limit(10);

// Here we exceed the limit by 1 iteration and a `RuntimeLimit` error is thrown.
//
// This error cannot be caught in JavaScript, it can only be caught in Rust code.
let result = context.eval(Source::from_bytes(
r"
try {
for (let i = 0; i < 12; ++i) { }
} catch (e) {

}
",
));
assert!(result.is_err());

Synthetic modules

We added support for creating synthetic modules from Rust code. This allows exposing a set of functions and properties to other modules without having to evaluate Javascript code.

// Taken from https://github.com/boa-dev/boa/blob/main/examples/src/bin/synthetic.rs
// See the file for the full example.

// ...

let sum = FunctionObjectBuilder::new(
context.realm(),
NativeFunction::from_fn_ptr(|_, args, ctx| {
args.get_or_undefined(0).add(args.get_or_undefined(1), ctx)
}),
)
.length(2)
.name(js_string!("sum"))
.build();

// ...

let operations = Module::synthetic(
// Make sure to list all exports beforehand.
&[
js_string!("sum"),
// ...
],
// The initializer is evaluated every time a module imports this synthetic module,
// so we avoid creating duplicate objects by capturing and cloning them instead.
SyntheticModuleInitializer::from_copy_closure_with_captures(
|module, fns, _| {
println!("Running initializer!");
module.set_export(&js_string!("sum"), fns.0.clone().into())?;
// ...
Ok(())
},
(sum, /* ... */),
),
None,
context,
)

loader.insert(
PathBuf::from("./scripts/modules")
.canonicalize()?
.join("operations.mjs"),
operations,
);

// ...

Async eval

Due to popular demand, we added some APIs that allow running scripts in an asynchronous way, making it possible to share some workload between async tasks and the execution of the engine itself. Note that, by the single-threaded nature of JS engines, all futures returned by Boa cannot implement neither Send nor Sync.

let context = &mut Context::default();
let src = Source::from_bytes(r#"
let array = new Array([15, 20, 35, 123, 65, 12]);
array.sort();
console.log(array);
"#);
let src = Script::parse(src, None, context).unwrap();
let task = async move {
let result = src.evaluate_async(context).await.unwrap();
println!("{:?}", result.display());
}
block_on(join!(long_task(), task));

JsErasedError

Don't you hate when you try to ? a Result<T, JsError> and the compiler just complains saying something like

error[E0277]: `Rc<num_bigint::bigint::BigInt>` cannot be sent between threads safely
--> tests/tester/src/main.rs:190:52
|
190 | Context::default().eval(Source::from_bytes(""))?;
| ^ `Rc<num_bigint::bigint::BigInt>` cannot be sent between threads safely
|
= help: within `JsError`, the trait `Send` is not implemented for `Rc<num_bigint::bigint::BigInt>`
= help: the following other types implement trait `FromResidual<R>`:
<Result<T, F> as FromResidual<Yeet<E>>>
<Result<T, F> as FromResidual<Result<Infallible, E>>>

Well, say no more to missing Sends in your daily life! We present to you, JsErasedError!

Jokes aside, using JsError is difficult from an embedder's perspective because JsError can be any arbitrary value, including non-Send values such as JsObject, JsString or JsBigInt. This makes JsError automatically incompatible with libraries like anyhow or eyre that expect only Send errors.

To solve this, we introduced a new JsError::into_erased method which returns a thread-safe version of JsError that is compatible with anyhow, eyre and other error-reporting libraries.

fn main() -> eyre::Result<()> {
let context = &mut Context::default();
let value = context
.eval(Source::from_bytes(""))
.map_err(|err| err.into_erased(context))?; // No compiler errors!
}

Why not call it JsSendError instead of JsErasedError? Well, it is generally not possible to convert a JsError into a JsErasedError without losing some information in the conversion. However, JsSendError gave the appearance of being JsError but Send, which is really not true. JsErasedError, on the other hand, makes it clear the conversion is not lossless. Feel free to ping us if you have a better name for it though!

Optimizations

The following benchmarks below are taken from the v8 benchmark suite. This benchmark is deprecated, but is useful in this context to show the performance improvements between versions.

(higher numbers are better)

Boa VersionRichardsDeltaBlueCryptoRayTraceEarleyBoyerSplayNavierStokesTotal
v0.1629.029.242.110710511115.449.1
v0.1734.339.149.113411914111.956.2
v0.1849.853.952.116115215410291.5

Inline Caching

Thanks to the implementation of Object Shapes in version v0.17, we were able to further improve the performance of the engine by implementing Inline Caching. The concept of Inline Caching is based on the idea that a property access for a variable will usually only be applied to objects of similar Shapes. To picture this, let's examine the following code:

function attach(obj1, obj2) {
obj1.attach = obj2.getHandler();
}

On interpreters that don't implement any kind of caching, the previous code would have to make a property lookup for the getHandler method every time that method is called. This is really inefficient for a simple reason: getHandler could be inside obj2, or it could be inside obj2.prototype, or it could be inside obj2.prototype.prototype... in fact, getHandler could be anywhere on the inheritance chain of obj2!

The easy approach to solve this is to cache the method lookup inside obj2 itself using an associative map of some sorts. This is nice, but also a bit wasteful because we would be allocating a new associative map for all instances of obj2, even if the map is only really used inside attach.

What then? Well, we can apply the "inline" part of an inline cache now! Just allocate an array of all property accesses within the attach function and assign an index to every one of them. Initially, a property access is uninitialized. Once we reach a particular uninitialized property access, it performs the dynamic lookup and changes its corresponding array slot to be a weak reference to the object's shape. If we reach the same property access again, we can retrieve the stored shape and directly access the object's dense storage without doing a property lookup!

However, there's a caveat. If obj2.getHandler is evaluated twice with objects of different shapes, the stored shape would be invalid for the second property access. In this case, we can rollback the access to the uninitialized state and make a manual property lookup once again. This is known as monomorphic inline caching. There's also polymorphic inline caching, which stores several shapes per access instead of rolling back to the uninitialized state.

Currently we do eager monomorphic inline caching, so there is plently of room for improvements that we're planning to do in the future!

Road to 1.0

As Boa is being used by more projects it is important we can provide a stable and reliable API. We don't feel like we're quite there yet, but after a discussion with the team we have decided to aim for a 1.0 release in the near future. This will be a big milestone for us and we hope to have a lot of new features and improvements to show off by then.

We will keep our focus on the public API for those embedding Boa. We will also be working on improving the performance of the engine. If you wanted to offer feedback on the API feel free to reach out to us via Github or Discord.

You can keep an eye on the project to reach 1.0 here. We hopefully don't forsee this project getting much bigger as most issues such as spec conformance or performance are a going-concern.

Conclusion

How can you support Boa?

Boa is an independent JavaScript engine implementing the ECMAScript specification, we rely on the support of the community to keep it going. If you want to support us, you can do so by donating to our open collective. Proceeeds here go towards this very website, the domain name, and remunerating members of the team who have worked on the features released.

If financial contribution is not your strength, you can contribute by asking to be assigned to one of our open issues, and asking for mentoring if you don't know your way around the engine. Our contribution guide should help you here. If you are more used to working with JavaScript or frontend web development, we also welcome help to improve our web presence, either in our website, or in our testing representation page or benchmarks page. You can also contribute to our Criterion benchmark comparison GitHub action.

We are also looking to improve the documentation of the engine, both for developers of the engine itself and for users of the engine. Feel free to contact us in Discord.

Thank You

Once again, big thanks to all the contributors of this release!!