WebAssembly islands in a Vite React app wasm-bindgen exports, wasm-pack --target web, and a lazy island that frees its memory
Compile a Rust fuzzy search with wasm-bindgen and wasm-pack, load it on demand as one React island in Vite, and own its memory across StrictMode, errors, and frees.
Versions: Rust 1.99, wasm-bindgen 0.2.129, wasm-bindgen-test 0.3.79, wasm-pack 0.15.0, Vite 8.3.5, React 19.3.1, TypeScript 7.0.2, @vitejs/plugin-react 6.1.2, Bun 1.4 (install only), Node 22/26 for scripts. Every command below passed on macOS (Apple silicon, Node 26.12.1, rustc 1.99.0) and Linux x86_64 (Node 22.23.4, rustc 1.99.1).
What you'll build
You will build a small People directory page. Most of it is plain React: a heading, a paragraph, and a layout. One widget is different. A fuzzy name search, written in Rust and compiled to WebAssembly, tolerates typos, so typing "hoper" finds "Grace Hopper".
WebAssembly (wasm) is a compact binary format that browsers run next to JavaScript. In the words of JavaScript: The Definitive Guide (§15.15.4), it is "a low-level virtual machine bytecode format that is designed to integrate well with JavaScript interpreters in web browsers", and it can export functions that JavaScript calls.
The search widget is an island: one interactive piece of a page that loads its own code on demand while the rest of the page stays ordinary. Here the island's JavaScript and its .wasm file are not downloaded until React first renders the island.
Four tools do the work:
- wasm-bindgen is a Rust library plus a command-line tool. It generates glue code, the JavaScript that converts strings and arrays between JavaScript and Rust and wraps each export in a normal-looking function.
- wasm-pack is a driver that runs the Rust compiler, runs wasm-bindgen, optimizes the result, and writes an npm-style package folder called
pkg/. - Vite is the build tool and dev server for the page.
- React renders the page, with
React.lazyandSuspenseloading the island.
Every output block in this lesson comes from a real run. Where a block is shortened, lines were removed and none were edited.
A. Exporting Rust to JavaScript with wasm-bindgen
The idea
JavaScript cannot call an ordinary Rust function. Rust mangles function names, and a compiled .wasm file only speaks in numbers. In The Rust Programming Language (ch. 20), calling Rust from another language needs an #[unsafe(no_mangle)] annotation so the compiler keeps the symbol name stable. The #[wasm_bindgen] attribute does that for you. It emits an exported, unmangled symbol, and it generates the JavaScript glue that turns a string into bytes in WebAssembly memory and back.
That memory is called linear memory: one growable block of bytes owned by the WebAssembly module. JavaScript cannot see a Rust struct directly. It sees a small integer pointer into that block, and the glue wraps the pointer in a JavaScript class.
Start with the project's dependency manifest for the Rust side.
#### island-rs/Cargo.toml
[package]
name = "fuzzy-island"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "=0.2.129"
[dev-dependencies]
wasm-bindgen-test = "=0.3.79"
crate-type lists the kinds of library Cargo should build. A cdylib is a C-style dynamic library, the shape the WebAssembly toolchain needs for a .wasm file. The rlib entry is the ordinary Rust library format, kept so that cargo test and the integration test file can link against the crate. The = before each version is an exact pin. Cargo will use exactly 0.2.129 and never a newer compatible release, which matters later when we discuss version mismatches between the library and the command-line tool.
#### island-rs/src/lib.rs
//! A fuzzy name matcher compiled to WebAssembly for one widget on a page.
use wasm_bindgen::prelude::*;
/// Version string baked in at compile time, so the page can show which build loaded.
#[wasm_bindgen]
pub fn version() -> String {
format!("fuzzy-island {}", env!("CARGO_PKG_VERSION"))
}
/// Lowercase and keep only letters, digits and single spaces.
#[wasm_bindgen]
pub fn normalize(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for word in input.split_whitespace() {
let cleaned: String = word
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
if cleaned.is_empty() {
continue;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(&cleaned);
}
out
}
/// Levenshtein distance counted in Unicode scalar values, not bytes.
#[wasm_bindgen]
pub fn edit_distance(a: &str, b: &str) -> u32 {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<u32> = (0..=b.len() as u32).collect();
let mut cur = vec![0u32; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i as u32 + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
/// A search index that lives in WebAssembly memory. JavaScript holds a handle to it.
#[wasm_bindgen]
pub struct FuzzyIndex {
names: Vec<String>,
keys: Vec<String>,
}
#[wasm_bindgen]
impl FuzzyIndex {
/// Build an index. An empty list is an error, which JavaScript sees as a thrown exception.
#[wasm_bindgen(constructor)]
pub fn new(names: Vec<String>) -> Result<FuzzyIndex, JsError> {
if names.is_empty() {
return Err(JsError::new("FuzzyIndex needs at least one name"));
}
let keys = names.iter().map(|n| normalize(n)).collect();
Ok(FuzzyIndex { names, keys })
}
/// Number of names in the index (a JavaScript getter, not a method).
#[wasm_bindgen(getter)]
pub fn len(&self) -> usize {
self.names.len()
}
/// Best matches first. A word that starts with the query counts as distance 0;
/// otherwise a word may be at most one edit away per three query characters.
pub fn search(&self, query: &str, limit: usize) -> Vec<String> {
let q = normalize(query);
if q.is_empty() {
return Vec::new();
}
let max_edits = (q.chars().count() as u32 / 3).max(1);
let mut scored: Vec<(u32, usize)> = self
.keys
.iter()
.enumerate()
.map(|(i, key)| {
let prefix = key.split(' ').any(|w| w.starts_with(&q));
let best = key
.split(' ')
.map(|w| edit_distance(&q, w))
.min()
.unwrap_or(u32::MAX);
(if prefix { 0 } else { best }, i)
})
.filter(|(d, _)| *d <= max_edits)
.collect();
scored.sort();
scored
.into_iter()
.take(limit)
.map(|(_, i)| self.names[i].clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_punctuation_and_case() {
assert_eq!(normalize(" Ada LOVELACE!! "), "ada lovelace");
assert_eq!(normalize("O'Brien, Flann"), "obrien flann");
}
#[test]
fn distance_counts_chars_not_bytes() {
assert_eq!(edit_distance("kitten", "sitting"), 3);
assert_eq!(edit_distance("café", "cafe"), 1);
}
#[test]
fn search_ranks_prefix_then_typo() {
let idx = FuzzyIndex::new(vec![
"Grace Hopper".into(),
"Ada Lovelace".into(),
"Alan Turing".into(),
])
.unwrap();
assert_eq!(idx.search("ada", 5), vec!["Ada Lovelace"]);
assert_eq!(idx.search("turnig", 5), vec!["Alan Turing"]);
assert_eq!(idx.len(), 3);
}
}
The file has three kinds of export. version, normalize and edit_distance are free functions: each takes &str and returns String or u32, and wasm-bindgen maps those to JavaScript strings and numbers. FuzzyIndex is an exported struct. Its #[wasm_bindgen(constructor)] function becomes new FuzzyIndex(...), #[wasm_bindgen(getter)] turns len into a property read, and search becomes an ordinary method. The constructor returns Result<FuzzyIndex, JsError>. The wasm-bindgen guide says an Err is thrown in JavaScript as an exception, so an empty list never returns a half-built object. The Vec<String> parameter and return types become string[]. The scoring rule is simple: a word starting with the query scores 0, and otherwise the best edit distance must stay within one edit per three query characters, with a minimum of one. The mod tests block is private and uses plain #[test], so it runs only natively. The guide says wasm-bindgen tests must sit in the crate root or a pub mod, which is why the WebAssembly tests live in a separate file.
#### island-rs/tests/web.rs
//! Runs inside a real WebAssembly engine via `wasm-pack test --node`.
use fuzzy_island::{FuzzyIndex, edit_distance, normalize};
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
fn normalize_in_wasm() {
assert_eq!(normalize("Grace HOPPER"), "grace hopper");
}
#[wasm_bindgen_test]
fn distance_in_wasm() {
assert_eq!(edit_distance("flaw", "lawn"), 2);
}
#[wasm_bindgen_test]
fn search_in_wasm() {
let idx = FuzzyIndex::new(vec!["Grace Hopper".into(), "Margaret Hamilton".into()]).unwrap();
assert_eq!(idx.search("hoper", 3), vec!["Grace Hopper"]);
}
This file sits in tests/, so Cargo compiles it as its own crate that imports fuzzy_island through the rlib output. The #[wasm_bindgen_test] attribute marks a test that should run inside a real WebAssembly engine. With wasm-pack test --node, that engine is Node. The three tests repeat the same behavior as the native ones, but they execute the compiled .wasm, so they catch problems that exist only in the WebAssembly build, such as a missing target or a bad conversion.
What the real test output proves
Run the native tests first.
$ cargo test --quiet
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
exit=0
The first report is the three private unit tests from lib.rs, running as normal native code. The two reports with zero tests are the integration file and the doc-tests. Notice that the three #[wasm_bindgen_test] functions did not run natively, which is the reason a second command exists. Native tests prove the algorithm is right, but they say nothing about whether the WebAssembly build behaves the same.
Now run the same logic inside WebAssembly. The compile lines are trimmed here.
$ wasm-pack test --node
[INFO]: 🎯 Checking for the Wasm target...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.33s
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
Running unittests src/lib.rs (target/wasm32-unknown-unknown/debug/deps/fuzzy_island-45e50538c0ea0587.wasm)
no tests to run!
Running tests/web.rs (target/wasm32-unknown-unknown/debug/deps/web-1062e95c6d397abc.wasm)
running 3 tests
test search_in_wasm ... ok
test normalize_in_wasm ... ok
test distance_in_wasm ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 filtered out; finished in 0.01s
exit=0
Two details matter. The unit tests in src/lib.rs report "no tests to run!" because they use plain #[test] inside a private module, which matches the guide's warning. The three tests in tests/web.rs pass, and the file path ends in .wasm, which shows the code ran as WebAssembly rather than native machine code. Notice also the dev profile line: test builds skip optimizations, so they compile quickly but say nothing about the size of the shipped file. The tests also finished in a different order than they appear in the file, which is normal for a parallel test runner.
The generated TypeScript types
When wasm-pack finishes it writes pkg/fuzzy_island.d.ts, a declaration file that tells TypeScript the shape of the JavaScript glue. Here is the part that describes our exports, with the generic loader types trimmed.
export class FuzzyIndex {
free(): void;
[Symbol.dispose](): void;
/**
* Build an index. An empty list is an error, which JavaScript sees as a thrown exception.
*/
constructor(names: string[]);
/**
* Best matches first. A word that starts with the query counts as distance 0;
* otherwise a word may be at most one edit away per three query characters.
*/
search(query: string, limit: number): string[];
/**
* Number of names in the index (a JavaScript getter, not a method).
*/
readonly len: number;
}
export function edit_distance(a: string, b: string): number;
export function normalize(input: string): string;
export function version(): string;
Your Rust doc comments became TypeScript doc comments, so editors show them on hover. Vec<String> became string[] in both directions, the getter became readonly len: number, and usize became number. The constructor's type says nothing about throwing, because TypeScript has no checked exceptions. The class has free() and [Symbol.dispose](), two names for one operation. The generated JavaScript contains the line if (Symbol.dispose) FuzzyIndex.prototype[Symbol.dispose] = FuzzyIndex.prototype.free;, so where the runtime supports explicit resource management, a TypeScript using declaration frees the index when its block ends. A component's index lives longer than any block, so this lesson calls free() from an effect cleanup instead.
Ownership across the boundary
A FuzzyIndex is memory inside WebAssembly that JavaScript's garbage collector cannot see directly. The guide notes that you could forget to call .free() and leave Rust memory allocated. Where weak references are supported, a FinalizationRegistry (a JavaScript feature that runs a callback after an object is collected) cleans up eventually, whether or not you free explicitly. That is a safety net, not a schedule, so the island frees its index itself.
The next script uses the generated module in plain Node, with no browser and no Vite.
#### smoke-node.mjs
// Exercise the generated module in Node with no browser and no Vite.
// initSync takes the .wasm bytes directly, so no fetch() is involved.
import { readFileSync } from "node:fs";
import { initSync, normalize, edit_distance, FuzzyIndex, version } from "./island-rs/pkg/fuzzy_island.js";
const bytes = readFileSync(new URL("./island-rs/pkg/fuzzy_island_bg.wasm", import.meta.url));
initSync({ module: bytes });
console.log(version());
console.log(JSON.stringify(normalize(" Ada LOVELACE!! ")));
console.log(edit_distance("café", "cafe"));
const idx = new FuzzyIndex(["Grace Hopper", "Ada Lovelace", "Alan Turing"]);
console.log(idx.len, idx.search("turnig", 5));
try {
new FuzzyIndex([]);
} catch (e) {
console.log(`${e.constructor.name}: ${e.message}`);
}
idx.free();
console.log("after free(), handle field is", idx.__wbg_ptr);
initSync is the synchronous cousin of the default init: it takes the .wasm bytes you already read from disk and instantiates them immediately, so no network request is involved. After that, every export works like a normal function. The try block passes an empty array to trigger the Result::Err path, and the catch prints the class name and message of whatever was thrown. The last two lines call free() and then print __wbg_ptr, an internal field of the generated class that holds the pointer. It is shown here only to illustrate what free() does, and you should not read it in application code.
$ node smoke-node.mjs
fuzzy-island 0.1.0
"ada lovelace"
1
3 [ 'Alan Turing' ]
Error: FuzzyIndex needs at least one name
after free(), handle field is 0
exit=0
Each line proves one claim. version() returns the Cargo package version baked in at compile time. normalize stripped punctuation and case. edit_distance("café", "cafe") is 1, so distance counts characters, not bytes. The getter returned 3 and the typo "turnig" found "Alan Turing". The Err from Rust arrived as a thrown JavaScript Error with our exact message, and after free() the handle is zero. A zero handle is what produces the "null pointer passed to rust" error if you use the object again, which the guide describes for any moved or freed exported struct.
B. Building with wasm-pack --target web and loading it in Vite
Why the web target
wasm-pack can emit the same Rust in several JavaScript shapes. The --target flag chooses one, using wording from the wasm-bindgen and wasm-pack documentation:
| Target | What it produces |
|---|---|
bundler | Suitable for loading in bundlers like Webpack. |
web | Directly loadable in a web browser. The JavaScript can be imported as an ES module, but the WebAssembly must be manually instantiated and loaded. |
nodejs | Loadable via require as a Node.js CommonJS module. |
no-modules | Similar to web, with manual initialization, but meant to be included in web pages without any further postprocessing. |
We use web because it gives the island explicit control. The default export of the generated module is init, which the guide describes as a function that will "boot" the module and make it ready to use. Nothing downloads until you call it. That is exactly what an island wants: the page decides when to pay for the .wasm file. When you call init() with no arguments it finds the file itself, using import.meta.url to locate the .wasm relative to the JavaScript file. Because the output is a standard ES module (ESM), it works with any ESM-capable toolchain, not only one bundler.
The build step
The package.json file wires the commands to short names. Note that the TypeScript compiler, Vite and React are all pinned exactly.
#### package.json
{
"name": "fuzzy-island-app",
"private": true,
"type": "module",
"scripts": {
"wasm": "wasm-pack build island-rs --target web",
"typecheck": "tsc --noEmit -p .",
"build": "vite build",
"preview": "vite preview --port 4179 --strictPort"
},
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"typescript": "7.0.2",
"vite": "8.3.5"
}
}
The wasm script is the only place the target is chosen. "type": "module" makes the config files and .mjs scripts ESM. vite preview serves the already-built dist/ folder on a fixed port, so the browser smoke test later has a known address. --strictPort makes it fail instead of silently picking another port. Bun only installs these packages here. Scripts that run code use Node.
Now build the WebAssembly package.
$ wasm-pack build island-rs --target web
[INFO]: 🎯 Checking for the Wasm target...
[INFO]: 🌀 Compiling to Wasm...
Finished `release` profile [optimized] target(s) in 3.74s
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: ✨ Done in 4.93s
[INFO]: 📦 Your wasm pkg is ready to publish at island-rs/pkg.
exit=0
We added no profile settings, and the output shows the defaults at work. The wasm-pack book says build uses --release when you pass no profile, and the log confirms it with "release profile [optimized]". The Cargo.toml reference says wasm-opt, a post-compile optimizer, runs by default for release builds, and the log shows "Optimizing wasm binaries with wasm-opt". The warning about missing description, repository and license fields matters only if you publish the package, and this one has publish = false. The pkg/ folder now holds five files: fuzzy_island.js (the glue, about 12 kB), fuzzy_island.d.ts, fuzzy_island_bg.wasm (37,652 bytes), fuzzy_island_bg.wasm.d.ts, and a package.json. The same source built on Linux produced a 37,536-byte .wasm, so expect small size and hash differences between machines and compiler builds. For scale, the same crate built with --dev (no optimizations, debug info kept, no wasm-opt) came out at 281,192 bytes, about 7.5 times larger.
Vite configuration
#### vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
// The .wasm file is about 37 kB, far above the 4 kB inline limit,
// so Vite copies it to dist/assets with a content hash.
target: "es2022",
},
});
The config has one plugin, the React plugin, and one build option, target: "es2022". That option tells Vite which JavaScript language level to emit, and ES2022 is the first level that includes top-level await, which the island module uses in section C. The comment records Vite's asset rule. Per the Vite guide, a .wasm file under assetsInlineLimit is inlined as a base64 string, and otherwise it is a static asset fetched on demand. At about 37 kB, ours is fetched on demand. There is no WebAssembly-specific plugin here.
#### tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "island-rs/pkg"]
}
TypeScript only checks types here (noEmit), and Vite does the actual compiling. The important line is "include": ["src", "island-rs/pkg"]. Adding the generated package folder means the compiler reads fuzzy_island.d.ts, so every call into Rust is type-checked. strict turns on the full set of strict checks, and moduleResolution: "bundler" matches how Vite resolves imports. The vite/client types describe things like import.meta.url.
#### index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>People directory</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
This is the page shell. React will fill the root element, and the single type="module" script is the entry point Vite follows to find every other file. The .wasm file is not referenced here at all. Vite discovers it only by following imports, and only through the island's code. That is the whole point of the design: a visitor who never reaches the island never downloads the WebAssembly, and the page shell stays as small as any other React page.
What the production build proves
$ npx vite build
vite v8.3.5 building client environment for production...
transforming...
✓ 20 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.33 kB │ gzip: 0.23 kB
dist/assets/fuzzy_island_bg-uzwOJ8i4.wasm 37.65 kB │ gzip: 18.49 kB
dist/assets/FuzzyIsland-CnfUVLVr.js 5.18 kB │ gzip: 2.26 kB
dist/assets/jsx-runtime-BoGIGDf1.js 8.23 kB │ gzip: 3.11 kB
dist/assets/index-Bx8KG0N9.js 213.82 kB │ gzip: 67.14 kB
✓ built in 62ms
exit=0
Vite split the app into chunks, which are separate output files that the browser can load independently. The main index chunk (213.82 kB) holds React and the page. The island is its own 5.18 kB chunk, and the .wasm is a third file whose name ends in a content hash (uzwOJ8i4), a short fingerprint of the file's bytes so browsers can cache it safely. The .wasm stayed a separate asset rather than being inlined, as the config comment predicted.
The glue's default URL is what Vite rewrites. Here is the original line in the generated file and the line in the built chunk (from a Linux build, where the hash differs).
$ grep -n "import.meta.url" island-rs/pkg/fuzzy_island.js
358: module_or_path = new URL('fuzzy_island_bg.wasm', import.meta.url);
$ grep -o "new URL([^)]*)" dist/assets/FuzzyIsland-*.js
new URL(`/assets/fuzzy_island_bg-Bl9UjV85.wasm`,``+import.meta.url)
exit=0
The glue builds a URL from a relative file name and import.meta.url, the address of the current module. Vite recognizes the new URL(..., import.meta.url) pattern, copies the target file into dist/assets, and replaces the name with the hashed one. You write no loader code. If the hash differs on your machine, that is expected, because the hash tracks the bytes of the compiled file.
--target bundler also works in Vite 8.3.5
You may read that webpack is the only fully compatible bundler. The wasm-bindgen deployment page says so, and it adds that Rolldown is compatible with rolldown-plugin-wasm. That page does not mention Vite, so the claim was tested directly. Vite 8.1.0 (2026-06-23) added direct .wasm imports, listed in its changelog as "wasm: direct .wasm imports (WASM ESM Integration)". The Vite guide says Vite then reads the module's imports and exports from the binary, instantiates it, and re-exposes its exports as named ES module exports. A directly imported .wasm file behaves as an async module and needs top-level await.
The bundler target's glue imports the .wasm as a module, so it needs this feature. We ran the whole build with that target and with src/wasm.ts changed to import from pkg directly, with a stub init.
# src/wasm.ts uses: import { FuzzyIndex, version } from "../island-rs/pkg/fuzzy_island.js"; const init = async () => {};
$ wasm-pack build island-rs --target bundler
[INFO]: ✨ Done in 0.65s
[INFO]: 📦 Your wasm pkg is ready to publish at island-rs/pkg.
$ head -9 island-rs/pkg/fuzzy_island.js
/* @ts-self-types="./fuzzy_island.d.ts" */
import * as wasm from "./fuzzy_island_bg.wasm";
import { __wbg_set_wasm } from "./fuzzy_island_bg.js";
__wbg_set_wasm(wasm);
wasm.__wbindgen_start();
export {
FuzzyIndex, edit_distance, normalize, version
} from "./fuzzy_island_bg.js";
$ npx vite build
vite v8.3.5 building client environment for production...
transforming...
✓ 24 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.33 kB │ gzip: 0.23 kB
dist/assets/fuzzy_island_bg-Bl9UjV85.wasm 37.53 kB │ gzip: 18.41 kB
dist/assets/FuzzyIsland-CxQE3ZUN.js 5.49 kB │ gzip: 2.32 kB
dist/assets/jsx-runtime-BLDUFMlP.js 8.38 kB │ gzip: 3.18 kB
dist/assets/index-Dht4n97U.js 213.82 kB │ gzip: 67.14 kB
✓ built in 148ms
exit=0
$ node smoke-browser.mjs google-chrome http://localhost:4180/
initial: {"heading":"People directory","matches":["Grace Hopper"],"footer":"7 names · fuzzy-island 0.1.0"}
after typing 'liskof': {"heading":"People directory","matches":["Barbara Liskov"],"footer":"7 names · fuzzy-island 0.1.0"}
wasm and island requests:
/assets/FuzzyIsland-CxQE3ZUN.js text/javascript
/assets/fuzzy_island_bg-Bl9UjV85.wasm application/wasm
console and errors: (none)
The glue's first lines show the difference: import * as wasm from "./fuzzy_island_bg.wasm" has no init function, and the module starts itself. The build succeeded, the page produced the same search results, and the .wasm was still fetched as a separate asset after the island chunk. So the bundler target works in Vite 8.3.5. We still chose web, because the explicit init() is easier to reason about: you can see exactly where the download is triggered, you can share one promise across islands, and the same output works in toolchains that lack direct .wasm imports.
C. Mounting the island in React 19
The idea
We need four behaviors. The island's code and .wasm must load only when it is first rendered. Several islands must share one download. The Rust memory must be freed when the component goes away. A failure inside the island must not blank the page. Each file below handles one of these.
#### src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
This is the entry. It renders App inside StrictMode, a React wrapper that adds extra checks in development. According to the React docs, Strict Mode runs one extra setup and cleanup cycle for every Effect in development, and it re-renders components an extra time to find impure rendering. That is helpful for us, because it simulates an island being unmounted and mounted again. One of the pitfalls below exists only because of that behavior.
#### src/wasm.ts
// The only file that touches the generated glue. Everything else imports from here.
import init, { FuzzyIndex, version } from "../island-rs/pkg/fuzzy_island.js";
// init() fetches and instantiates fuzzy_island_bg.wasm. Calling it once and
// sharing the promise means every island on the page waits for the same download.
let ready: Promise<void> | undefined;
export function loadWasm(): Promise<void> {
ready ??= init().then(() => undefined);
return ready;
}
export { FuzzyIndex, version };
This file is the single point of contact with the generated package, so a later change of target touches one file. init is the default import, and loadWasm wraps it. The ??= operator assigns only if ready is still undefined, so the first caller starts the download and every later caller receives the same promise. .then(() => undefined) narrows the type to Promise<void>, which hides the loader's return value. The file also re-exports FuzzyIndex and version so components never import from pkg themselves.
#### src/FuzzyIsland.tsx
import { useEffect, useState } from "react";
import { FuzzyIndex, loadWasm, version } from "./wasm";
// React.lazy wants a default export that is a component. Awaiting loadWasm()
// here means the component code only runs after the module is instantiated.
await loadWasm();
type Props = { names: string[]; initialQuery: string };
export default function FuzzyIsland({ names, initialQuery }: Props) {
const [index, setIndex] = useState<FuzzyIndex | null>(null);
const [query, setQuery] = useState(initialQuery);
// Create the Rust-side index in an effect and free it in the cleanup, so each
// mount owns exactly one index and no freed handle is ever reused.
useEffect(() => {
const idx = new FuzzyIndex(names);
setIndex(idx);
return () => {
setIndex(null);
idx.free();
};
}, [names]);
const matches = index ? index.search(query, 5) : [];
return (
<section aria-label="fuzzy search">
<label>
Find a person{" "}
<input value={query} onChange={(e) => setQuery(e.target.value)} />
</label>
<ul data-testid="matches">
{matches.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
<p>
<small>
{index ? `${index.len} names` : "building index"} · {version()}
</small>
</p>
</section>
);
}
The line await loadWasm(); at the top of the module is top-level await: the module itself becomes asynchronous and finishes evaluating only after the promise settles. Because React.lazy waits for the module's import to complete, the component can assume WebAssembly is ready, so calling version() during render is safe. The index is created inside useEffect, not during render. The cleanup clears the state first and then calls idx.free(), so no later render can touch a freed handle. Before the first effect runs, index is null, so matches is an empty array and the footer reads "building index". The effect depends on names, so the caller should pass a stable array. A new array on every render would rebuild the index each time. App passes a module-level constant for that reason.
#### src/App.tsx
import { lazy, Suspense } from "react";
import { IslandBoundary } from "./IslandBoundary";
// The island's code, the generated glue and the .wasm file load only when
// this component first renders. The rest of the page does not wait for them.
const FuzzyIsland = lazy(() => import("./FuzzyIsland"));
const PEOPLE = [
"Grace Hopper",
"Ada Lovelace",
"Alan Turing",
"Margaret Hamilton",
"Katherine Johnson",
"Edsger Dijkstra",
"Barbara Liskov",
];
export default function App() {
return (
<main>
<h1>People directory</h1>
<p>This paragraph is plain React and renders before any WebAssembly loads.</p>
<IslandBoundary fallback={<p role="alert">Search is unavailable right now.</p>}>
<Suspense fallback={<p>Loading search…</p>}>
<FuzzyIsland names={PEOPLE} initialQuery="hoper" />
</Suspense>
</IslandBoundary>
</main>
);
}
lazy(() => import("./FuzzyIsland")) is the code-splitting pattern from Learning React, chapter 9: React.lazy wraps a dynamic import() and tells the bundler to make that module a separate chunk. Nothing in the chunk loads until React first tries to render FuzzyIsland. While it loads, the nearest Suspense shows its fallback, "Loading search…". The heading and paragraph are plain React and render without waiting. The IslandBoundary sits outside Suspense, so it catches errors from the import itself (a failed chunk or .wasm download) as well as errors during render.
#### src/IslandBoundary.tsx
import { Component, type ReactNode } from "react";
type Props = { fallback: ReactNode; children: ReactNode };
type State = { failed: boolean };
// If the island's chunk, its .wasm file, or its first render throws, React
// renders the fallback here instead of unmounting the whole page.
export class IslandBoundary extends Component<Props, State> {
state: State = { failed: false };
static getDerivedStateFromError(): State {
return { failed: true };
}
componentDidCatch(error: unknown) {
console.error("island failed:", error instanceof Error ? error.message : error);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
An error boundary is a component that catches errors thrown by its children and shows a fallback instead of crashing the app. Learning React (ch. 9) notes that the only way to make one is a class component. getDerivedStateFromError flips a flag so the next render shows the fallback, and componentDidCatch logs a short message that starts with "island failed:". That prefix is what you will see in the logs below. Without this boundary, one failed .wasm download would unmount the whole React tree and leave a blank page.
Checking it in a real browser
The browser smoke test starts headless Chrome, opens the page, reads the DOM, types into the input, and records network responses and console output. It talks to Chrome over the DevTools Protocol, using the WebSocket client built into Node 22 and later.
#### smoke-browser.mjs
// Headless Chrome check of a running Vite server (dev or preview).
// Usage: node smoke-browser.mjs <chrome-binary> <page-url>
// Uses the Chrome DevTools Protocol over the WebSocket built into Node 22+.
import { spawn } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const [chromeBin, pageUrl] = process.argv.slice(2);
const port = 9333;
const chrome = spawn(chromeBin, [
"--headless=new",
"--no-first-run",
"--disable-gpu",
`--remote-debugging-port=${port}`,
`--user-data-dir=${mkdtempSync(join(tmpdir(), "island-chrome-"))}`,
"about:blank",
], { stdio: "ignore" });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let target;
for (let i = 0; i < 50 && !target; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
target = list.find((t) => t.type === "page");
} catch { await sleep(100); }
}
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r) => ws.addEventListener("open", r, { once: true }));
let nextId = 1;
const pending = new Map();
const requests = [];
const logs = [];
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg.result); pending.delete(msg.id); }
if (msg.method === "Network.responseReceived") {
const { url, mimeType } = msg.params.response;
requests.push(`${new URL(url).pathname} ${mimeType}`);
}
if (msg.method === "Runtime.consoleAPICalled") {
logs.push(`console.${msg.params.type}: ${msg.params.args.map((a) => a.value ?? a.description).join(" ")}`);
}
if (msg.method === "Runtime.exceptionThrown") {
const d = msg.params.exceptionDetails;
logs.push(`uncaught: ${d.exception?.description?.split("\n")[0] ?? d.text}`);
}
});
const send = (method, params = {}) => new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params }));
});
const evaluate = async (expression) =>
(await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })).result.value;
await send("Network.enable");
await send("Runtime.enable");
await send("Page.navigate", { url: pageUrl });
for (let i = 0; i < 50; i++) {
if (await evaluate(`document.querySelectorAll("[data-testid=matches] li").length > 0`)) break;
await sleep(100);
}
const show = async (label) => console.log(label, JSON.stringify(await evaluate(`({
heading: document.querySelector("h1")?.textContent,
matches: [...document.querySelectorAll("[data-testid=matches] li")].map((li) => li.textContent),
footer: document.querySelector("section small")?.textContent,
alert: document.querySelector("[role=alert]")?.textContent,
})`)));
await show("initial:");
// Type a new query the way React expects: set the value through the native setter, then fire input.
await evaluate(`(() => {
const input = document.querySelector("section input");
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
set.call(input, "liskof");
input.dispatchEvent(new Event("input", { bubbles: true }));
})()`);
await sleep(200);
await show("after typing 'liskof':");
console.log("wasm and island requests:");
for (const r of requests.filter((r) => /wasm|FuzzyIsland|fuzzy_island/.test(r))) console.log(" " + r);
console.log("console and errors:", logs.length ? "" : "(none)");
for (const l of logs) console.log(" " + l);
ws.close();
chrome.kill();
Read the script in four parts. The top launches Chrome with a throwaway profile folder and a debugging port, then polls until a page target appears. The middle opens a WebSocket and records three event types: network responses (path and MIME type), console calls, and uncaught exceptions. The show function reads the heading, the list of matches, the footer text and any role=alert element straight from the DOM. The typing step sets the input's value through the browser's native setter before firing an input event, because React ignores a plain assignment. At the end, the script prints only requests whose path mentions wasm, FuzzyIsland or fuzzy_island, in the order the responses arrived.
First the production build, served by vite preview:
$ node smoke-browser.mjs /Applications/Google Chrome.app/Contents/MacOS/Google Chrome http://localhost:4179/
initial: {"heading":"People directory","matches":["Grace Hopper"],"footer":"7 names · fuzzy-island 0.1.0"}
after typing 'liskof': {"heading":"People directory","matches":["Barbara Liskov"],"footer":"7 names · fuzzy-island 0.1.0"}
wasm and island requests:
/assets/FuzzyIsland-CnfUVLVr.js text/javascript
/assets/fuzzy_island_bg-uzwOJ8i4.wasm application/wasm
console and errors: (none)
exit=0
The two DOM snapshots come straight from the page. The initial query "hoper" (one letter short) matched "Grace Hopper", and after the script typed "liskof" the list switched to "Barbara Liskov", a one-letter substitution. The request list shows the island chunk arriving before the hashed .wasm, served with the application/wasm MIME type (the Content-Type header value that names a file's format). That header matters: the generated glue first tries WebAssembly.instantiateStreaming, which compiles while the bytes download, and if the server sends any other Content-Type it logs a warning and falls back to the slower WebAssembly.instantiate. The console stayed empty, so the fast path worked.
Then the dev server, where Vite serves source files directly:
$ node smoke-browser.mjs /Applications/Google Chrome.app/Contents/MacOS/Google Chrome http://localhost:5179/
initial: {"heading":"People directory","matches":["Grace Hopper"],"footer":"7 names · fuzzy-island 0.1.0"}
after typing 'liskof': {"heading":"People directory","matches":["Barbara Liskov"],"footer":"7 names · fuzzy-island 0.1.0"}
wasm and island requests:
/src/FuzzyIsland.tsx text/javascript
/src/wasm.ts text/javascript
/island-rs/pkg/fuzzy_island.js text/javascript
/island-rs/pkg/fuzzy_island_bg.wasm application/wasm
console and errors:
console.debug: [vite] connecting...
console.info: %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
console.debug: [vite] connected.
exit=0
In both runs the initial query "hoper" returned "Grace Hopper" and, after typing "liskof", the list changed to "Barbara Liskov". The footer shows seven names and the version string, so both a getter and a free function crossed the boundary. In production, the island chunk response arrives first and the .wasm follows, which is what the lazy loading design promises. In dev the order is the same idea: the island source, then the glue, then the .wasm. The production run printed no console messages, and the dev run printed only Vite and React DevTools notices, with no errors. No role=alert text appeared in either case, so the error boundary stayed idle.
Pitfalls
Calling an export before init
Symptom: TypeError: Cannot read properties of undefined (reading '__wbindgen_free')
Cause: With --target web, the glue holds a module-level reference to the WebAssembly instance that stays undefined until init() (or initSync) finishes. In the log, a script imported version and called it immediately. The first access to the missing instance failed inside the glue, far from the line that was actually wrong.
Fix: Always wait for loadWasm() before using any export. In this project that is the await loadWasm(); at the top of FuzzyIsland.tsx. Import exports only through src/wasm.ts so there is one place to enforce the order.
Use after free
Symptom: Error: null pointer passed to rust
Cause: After idx.free(), the handle's pointer is zero, as the __wbg_ptr line in the Node smoke test shows. The log calls idx.search("grace", 3) right after free(), and the glue throws instead of reading invalid memory. The wasm-bindgen guide describes the same message for any exported struct that has been moved or freed.
The message depends on the build profile. With wasm-pack build --dev, the glue checks the pointer in JavaScript before calling into WebAssembly and throws a clearer error from the generated file:
if (this.__wbg_ptr == 0) throw new Error('Attempt to use a moved value');
^
Error: Attempt to use a moved value
This matches the guide's note that a moved value reports "Attempt to use a moved value" in debug builds and "null pointer passed to rust" otherwise. If you are chasing a lifetime bug, a --dev build gives the friendlier message and a stack that stops at your call.
Fix: Treat free() as the end of the object's life. Give each owner one handle, clear any reference in the same cleanup that frees it, and never keep a freed index in a variable that later code might read. The effect in FuzzyIsland.tsx does this by calling setIndex(null) before idx.free().
StrictMode, useMemo and free in cleanup
Symptom: the page works, then after the first keystroke the dev server shows Search is unavailable right now. and the console prints island failed: null pointer passed to rust.
Cause: This variant looked tidy but is wrong:
const index = useMemo(() => new FuzzyIndex(names), [names]);
const [query, setQuery] = useState(initialQuery);
// Looks tidy, but StrictMode runs this cleanup once right after mount.
useEffect(() => () => index.free(), [index]);
In development, StrictMode runs each effect's setup and cleanup once right after mount. The cleanup freed the memoized index, but useMemo kept returning the same object. The first paint looked correct because rendering had already happened before the extra cleanup ran, and nothing re-rendered until a keystroke. The next render, triggered by typing, called search on a freed handle. The log shows two stack traces ending in the same message: one from FuzzyIndex.search inside FuzzyIsland, and one from FuzzyIndex.free inside the unmount of the effect, where freeing an already freed handle also throws. The production build with the same code worked and found "Barbara Liskov", because StrictMode's extra cycle is development-only. The error boundary caught the dev failure, and the heading stayed on screen.
Fix: Create the index inside useEffect and free it in that same effect's cleanup, as in the final FuzzyIsland.tsx. Each setup then makes a fresh index, so StrictMode's extra cycle frees one index and builds another.
Wrong target nodejs
Symptom: ReferenceError: __dirname is not defined, preceded at build time by Module "fs" has been externalized for browser compatibility.
Cause: The nodejs target emits CommonJS code (Node's older module format, built on require and module-scoped variables such as __dirname) that reads the .wasm from disk. The log shows it contains const wasmPath = ${__dirname}/fuzzy_island_bg.wasm; and require('fs').readFileSync(wasmPath), which cannot work in a browser. Vite built the page anyway, with only the warning, and no .wasm file appeared in dist/assets. At runtime the island chunk threw as soon as it ran. The error boundary showed "Search is unavailable right now." and the rest of the page survived, with console.error: island failed: __dirname is not defined.
Fix: Build with --target web (or bundler on Vite 8.1.0 and later). Read the Vite build log for the "externalized" warning, because it is the early signal that a Node-only module reached the browser.
wasm-bindgen CLI vs library schema mismatch
Symptom:
it looks like the Rust project used to create this Wasm file was linked against
version of wasm-bindgen that uses a different bindgen format than this binary:
rust Wasm file schema version: 0.2.100
this binary schema version: 0.2.129
(The full message begins error: failed to read wasm-bindgen data from 'target/wasm32-unknown-unknown/release/fuzzy_island.wasm'.)
Cause: The Rust library compiled into your .wasm and the command-line tool that post-processes it must agree on an internal data format, called the schema. In the log, Cargo.toml pinned =0.2.100 while the wasm-bindgen tool on the PATH was 0.2.129, so the tool refused to read the file. A related test pinned =0.2.128 against the same 0.2.129 tool and it worked. The SCHEMA_VERSION constants show why: 0.2.128 and 0.2.129 both declare schema "0.2.128". That is luck of the release history, so do not rely on it. The message is also slightly misleading. In the wasm-bindgen-cli-support 0.2.129 source, the check compares the schema_version recorded in the .wasm against the tool's SCHEMA_VERSION, but the two lines labelled "schema version" print the crate versions (0.2.100 and 0.2.129). Do not conclude from the message that schema numbers always equal release numbers.
Fix: Pin the library with = and let wasm-pack choose the tool. When Cargo.toml pinned =0.2.100, wasm-pack build --target web printed "Installing wasm-bindgen..." and succeeded, because it fetched a matching CLI itself. Avoid running a separately installed wasm-bindgen binary by hand.
TypeScript catches argument order
Symptom: src/FuzzyIsland.tsx(25,40): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
Cause: The log changed the call to index.search(5, query). A pure JavaScript wrapper would have passed the swapped arguments straight to Rust, where they would fail in a confusing way. Because tsconfig.json includes island-rs/pkg, TypeScript reads the generated .d.ts, which says search(query: string, limit: number), and rejects the call.
Fix: Keep the generated package folder in include and run tsc --noEmit in CI. Do not hand-write types for the glue, because the generated ones track your Rust signatures exactly.
Fresh clone without pkg
Symptom: src/wasm.ts(2,43): error TS2307: Cannot find module '../island-rs/pkg/fuzzy_island.js' or its corresponding type declarations.
Cause: The generated pkg/ folder is build output, so a fresh clone does not have it. The log shows two more errors from the same run (TS7006 for an implicit any on m, and TS2322 in wasm.ts), which are knock-on effects of the unresolved import.
Fix: Run wasm-pack build island-rs --target web before tsc or vite build. If you see a burst of unrelated TypeScript errors in a fresh checkout, fix the TS2307 first and re-run.
Run it yourself
You need Rust, wasm-pack, Bun, Node, and a Chrome binary. Add the WebAssembly compile target once with rustup target add wasm32-unknown-unknown; wasm-pack checks for it (the "Checking for the Wasm target" line) before compiling. A target here is the platform Rust compiles for, and wasm32-unknown-unknown means 32-bit WebAssembly with no assumed operating system. Create the files shown above in a folder named fuzzy-island-app. The Rust files go under island-rs/, the React files under src/, and the rest sit in the project root. Generate a lockfile once with bun install, since run-lab.sh installs with --frozen-lockfile. Then run-lab.sh rebuilds and checks everything from a clean checkout.
#### run-lab.sh
#!/usr/bin/env bash
# Rebuild and check everything from a clean checkout.
# Needs: rustup with the wasm32-unknown-unknown target, wasm-pack, bun, node, Chrome.
# CHROME=/path/to/chrome overrides the browser used by the smoke test.
set -uo pipefail
cd "$(dirname "$0")"
CHROME="${CHROME:-google-chrome}"
run() {
echo "\$ $*"
"$@"
echo "exit=$?"
echo
}
run rustc --version
run wasm-pack --version
run node --version
run bun --version
run bun install --frozen-lockfile
(cd island-rs && run cargo test --quiet)
(cd island-rs && run wasm-pack test --node)
run wasm-pack build island-rs --target web
run ls -l island-rs/pkg
run npx tsc --version
run npx tsc --noEmit -p .
run npx vite build
run node smoke-node.mjs
npx vite preview --port 4179 --strictPort > /tmp/fuzzy-island-preview.log 2>&1 &
PREVIEW=$!
sleep 2
run node smoke-browser.mjs "$CHROME" http://localhost:4179/
kill "$PREVIEW"
npx vite --port 5179 --strictPort > /tmp/fuzzy-island-dev.log 2>&1 &
DEV=$!
sleep 3
run node smoke-browser.mjs "$CHROME" http://localhost:5179/
kill "$DEV"
The run helper prints each command with a $ prefix, runs it, and prints its exit code, which is why every block above ends in exit=. The order is deliberate. Versions come first so a log is self-describing. bun install --frozen-lockfile fails if the lockfile and package.json disagree. Native cargo test and wasm-pack test --node come before the build because they are quick. wasm-pack build must precede tsc, since the type check reads pkg/. After vite build, the Node smoke test checks the glue alone. The script then starts vite preview in the background, tests the production output, stops it, and repeats the browser test against the dev server. Set CHROME to your browser binary if it is not named google-chrome. On macOS that is CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ./run-lab.sh, which is how the macOS logs in this lesson were produced. The script keeps going after a failed step, so scan the log for any exit= other than 0.
Check yourself
- **Why does this project use
--target webinstead of--target bundler?** Thewebtarget gives an explicitinit()function, so the island decides when the.wasmfile downloads, and the ES module output works with any ESM toolchain. Thebundlertarget also builds and runs in Vite 8.3.5, because Vite 8.1.0 added direct.wasmimports. The wasm-bindgen deployment page still names webpack, and Rolldown withrolldown-plugin-wasm, as the known-compatible bundlers. We chosewebfor control, not because the other target fails. - **What happens if you call
index.search(...)afterindex.free(), and why?** It throwsError: null pointer passed to rust.free()releases the Rust memory and zeroes the handle's pointer field. A later method call sees that null pointer and the glue throws instead of reading freed memory. AFinalizationRegistrycan clean up objects you forget to free, but it never makes a freed handle usable again. - **Why is the
FuzzyIndexcreated inuseEffectand not inuseMemo?** In development, StrictMode runs an extra setup and cleanup cycle for every effect. If the cleanup frees an index thatuseMemocreated, the next render reuses that same freed object and fails. Creating the index in the effect means each setup builds a fresh one, and each cleanup frees only the one it made. - **How does the
.wasmfile load only when the island is rendered?**App.tsxusesReact.lazywith a dynamicimport(), so the island code is a separate chunk that React fetches on first render. That chunk starts withawait loadWasm(), which callsinit(), andinit()fetches the hashed.wasmasset using a URL built fromimport.meta.url. Network logs show the island chunk arriving before the.wasm. - What stops a failed island from blanking the whole page?
IslandBoundaryis a class component withgetDerivedStateFromErrorandcomponentDidCatch. When the chunk, the.wasmdownload or a render throws, it renders the fallbackSearch is unavailable right now.and logsisland failed: .... The heading and paragraph, which are outside the boundary, stay on screen. The wrong-target and StrictMode logs both show this.
Sources
- wasm-bindgen Guide, "Deploying Rust and WebAssembly": https://wasm-bindgen.github.io/wasm-bindgen/reference/deployment.html
- wasm-bindgen Guide, "Without a Bundler" example: https://wasm-bindgen.github.io/wasm-bindgen/examples/without-a-bundler.html
- wasm-bindgen Guide, "Exported struct Rust Types": https://wasm-bindgen.github.io/wasm-bindgen/reference/types/exported-rust-types.html
- wasm-bindgen Guide, "Result<T, E>": https://wasm-bindgen.github.io/wasm-bindgen/reference/types/result.html
- wasm-bindgen Guide, "Support for Weak References": https://wasm-bindgen.github.io/wasm-bindgen/reference/weak-references.html
- wasm-bindgen Guide, "Using wasm-bindgen-test": https://wasm-bindgen.github.io/wasm-bindgen/wasm-bindgen-test/usage.html
- wasm-pack book, "wasm-pack build": https://wasm-bindgen.github.io/wasm-pack/book/commands/build.html
- wasm-pack book, "Cargo.toml Configuration": https://wasm-bindgen.github.io/wasm-pack/book/cargo-toml-configuration.html
- Vite 8 guide, "Features: WebAssembly": https://vite.dev/guide/features#webassembly, and the Vite CHANGELOG entry for 8.1.0 (2026-06-23), "wasm: direct .wasm imports (WASM ESM Integration) (#21779)"
- React docs, "StrictMode": https://react.dev/reference/react/StrictMode
- David Flanagan, JavaScript: The Definitive Guide (7th ed.), §15.15.4 "WebAssembly", p. 984
- Steve Klabnik and Carol Nichols, The Rust Programming Language, ch. 20 "Unsafe Rust: Calling Rust Functions from Other Languages", pp. 600-601
- Alex Banks and Eve Porcello, Learning React (2nd ed.), ch. 9 "Suspense": "Error Boundaries" p. 216 and "Code Splitting" p. 221