feat: router-based wasm bundle splitting (#3683)
* feat: wasm-split base implementation * kinda sorta works with the cli * okay great it actually works * fix: cache busting for chunks * cleaned up version * it works with the router * wip: migrate to a faster linear solution using id remapping * add a good harness * it works completely on the docsite * the super tight build works too * make parallelizable * works on the docsite but not the harness * light cleanups, fix for both harness and docsite * make codegen smaller by extract some shared functions to prevent monos * chunking works, but tbd on tuning heuristics * extract used module * wip: cleaning up module * wip: much better understanding of how imports work * fix the call graph * working again... * wip: thinking about ifuncs * ifunc approach works * yes the ifunc approach works!! * it all works completely even with chunks * small cleanups * comments * actually locate all of the symbols * clean up final implementation * clean up tomls, revert some mono changs * clean up, add some more comments / docs in places * hoist more packages * clean up impl a bit more * clean cli * move harness to playwright * update playwright * extract codgen * add lazy to components * Add docs to split loader * clippy * typos * clippy, don't generate splits if not on wasm * whoops, const * Typos and docs * enable optimizations in ci * add optimizations to ci * add wasm-split router feature * parallel playwright? * fix no case check * disable pre-compress... think that might be the issue * better logging, no default pre-compress, caching of shared symbols * Fix: compressing assets * fix non-interactive logging for serve * cache playwright even on failure, use the cli with optimizations * fix prebuild * fix playwright webserver
This commit is contained in:
1 parent
14eb7597df
commit
bdeedc13eb
59 files changed
+4249
-299
No files matched your search
@@ -142,11 +142,20 @@ module.exports = defineConfig({
|
||||
cwd: path.join(process.cwd(), "cli-optimization"),
|
||||
// Remove the cache folder for the cli-optimization build to force a full cache reset
|
||||
command:
|
||||
'cargo run --package dioxus-cli --release -- serve --addr "127.0.0.1" --port 8989',
|
||||
'cargo run --package dioxus-cli --release --features optimizations -- serve --addr "127.0.0.1" --port 8989',
|
||||
port: 8989,
|
||||
timeout: 50 * 60 * 1000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "pipe",
|
||||
},
|
||||
{
|
||||
cwd: path.join(process.cwd(), "wasm-split-harness"),
|
||||
command:
|
||||
'cargo run --package dioxus-cli --release --features optimizations -- serve --bin wasm-split-harness --platform web --addr "127.0.0.1" --port 8001 --experimental-wasm-split --profile wasm-split-release',
|
||||
port: 8001,
|
||||
timeout: 50 * 60 * 1000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "pipe",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "wasm-split-harness"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
authors = ["Jonathan Kelley"]
|
||||
|
||||
[dependencies]
|
||||
dioxus = { workspace = true, features = ["web", "router", "wasm-split"] }
|
||||
dioxus-router = { workspace = true, features = ["wasm-split"] }
|
||||
anyhow = { workspace = true }
|
||||
async-compression = { workspace = true, features = ["futures-io", "gzip", "brotli"] }
|
||||
futures = { workspace = true }
|
||||
js-sys = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
wasm-bindgen-futures = { workspace = true }
|
||||
web-sys = { workspace = true, features = ["Document", "Window", "HtmlElement", "Text", "DomRectReadOnly", "console"] }
|
||||
once_cell = { workspace = true }
|
||||
getrandom = { workspace = true, features = ["js"] }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
harness/
|
||||
@@ -0,0 +1,13 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hello wasm-bindgen</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
</body>
|
||||
<script type="module">
|
||||
import init from "/harness/split/main.js";
|
||||
init();
|
||||
</script>
|
||||
</html>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
cargo build --bin wasm-split-cli
|
||||
CLI=./target/debug/wasm-split-cli
|
||||
|
||||
rm -rf docsite/chunks
|
||||
|
||||
# Run the wasm-split-cli on the with_body.wasm file
|
||||
${CLI} split docsite/input.wasm docsite/bindgen/main_bg.wasm docsite/chunks
|
||||
|
||||
# copy the contents of the wasm_bindgen folder to the docsite folder
|
||||
mv docsite/chunks/main.wasm docsite/chunks/main_bg.wasm # rename the main wasm file
|
||||
cp -r docsite/bindgen/snippets docsite/chunks/snippets
|
||||
cp docsite/bindgen/main.js docsite/chunks/main.js
|
||||
|
||||
python3 -m http.server 8080 --directory docsite
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# This file is a simple shell script that runs the bundle split process manually without the CLI involved
|
||||
# it's not necessarily meant to work on your machine (sorry!)
|
||||
#
|
||||
# To hack on harness you need the `wasm-tools` CLI installed
|
||||
# `cargo binstall wasm-tools`
|
||||
#
|
||||
# This script is also sensitive to where it's run from, so you *need* to be in the harness folder (running as `./run.sh`)
|
||||
|
||||
TARGET_DIR=../../../target
|
||||
|
||||
# build the harness
|
||||
cargo rustc --package wasm-split-harness --target wasm32-unknown-unknown --profile wasm-split-release -- -Clink-args=--emit-relocs
|
||||
|
||||
# for a much smaller compile, you can crank up the flags. However, dioxus relies heavily on location detail, so we can't disable that
|
||||
#
|
||||
# -Zlocation-detail=none - we could compile with location detail off but if breaks our signals...
|
||||
#
|
||||
# cargo +nightly rustc \
|
||||
# -Z build-std=std,panic_abort \
|
||||
# -Z build-std-features="optimize_for_size" \
|
||||
# -Z build-std-features=panic_immediate_abort \
|
||||
# --target wasm32-unknown-unknown \
|
||||
# --no-default-features \
|
||||
# --profile wasm-split-release \
|
||||
# -- -Clink-args=--emit-relocs
|
||||
|
||||
# Build the wasm-split-cli. We are going to call it directly since it's so noisy to build it multiple times
|
||||
cargo build --package wasm-split-cli --bin wasm-split-cli
|
||||
CLI=$TARGET_DIR/debug/wasm-split-cli
|
||||
|
||||
# clear the workdir and assemble the new structure
|
||||
rm -rf data/harness
|
||||
mkdir -p data/harness/split
|
||||
mkdir -p data/harness/split_not
|
||||
|
||||
# copy the output wasm file to the harness dir
|
||||
cp $TARGET_DIR/wasm32-unknown-unknown/wasm-split-release/wasm-split-harness.wasm data/harness/input.wasm
|
||||
|
||||
# Run wasm-bindgen on this module, without splitting it
|
||||
wasm-bindgen data/harness/input.wasm --out-dir data/harness/split_not --target web --out-name main --no-demangle --no-typescript --keep-lld-exports --keep-debug
|
||||
|
||||
# Run the wasm-split-cli on the with_body.wasm file
|
||||
${CLI} split data/harness/input.wasm data/harness/split_not/main_bg.wasm data/harness/chunks
|
||||
|
||||
# copy over the chunks
|
||||
paths=$(ls data/harness/chunks/ | grep "\.wasm")
|
||||
for path in $paths
|
||||
do
|
||||
|
||||
path_without_ext=${path%.*}
|
||||
wasm-opt -Oz data/harness/chunks/$path -o data/harness/split/$path --enable-reference-types --memory-packing --debuginfo
|
||||
|
||||
# remove stuff like manganis, etc
|
||||
wasm-tools strip data/harness/split/$path -o data/harness/split/$path
|
||||
|
||||
# if you don't want names (making it harder to debug the outputs) use `--all`
|
||||
# wasm-tools strip data/harness/split/$path -o data/harness/split/$path --all
|
||||
done
|
||||
|
||||
|
||||
# rename the main chunk
|
||||
mv data/harness/split/main.wasm data/harness/split/main_bg.wasm
|
||||
cp data/harness/split_not/main.js data/harness/split/main.js
|
||||
cp -r data/harness/split_not/snippets data/harness/split/snippets
|
||||
cp data/harness/chunks/__wasm_split.js data/harness/split/__wasm_split.js
|
||||
|
||||
wasm-opt -Oz data/harness/split_not/main_bg.wasm -o data/harness/split_not/main_bg_opt.wasm --enable-reference-types --memory-packing --debuginfo
|
||||
|
||||
# Run wasm-strip to strip out the debug symbols
|
||||
wasm-tools strip data/harness/split_not/main_bg_opt.wasm -o data/harness/split_not/main_bg_opt.wasm
|
||||
|
||||
# if you don't want names (making it harder to debug the outputs) use `--all`
|
||||
# wasm-tools strip data/harness/split/$path -o strip data/harness/split_not/main_bg_opt.wasm --all
|
||||
|
||||
echo "===========================================================================\n"
|
||||
ls -l data/harness/split_not/main_bg_opt.wasm | awk '{ printf("%07d -> ", $5);print $9}'
|
||||
echo ""
|
||||
ls -l data/harness/split | grep "\.wasm" | awk '{ printf("%07d -> ", $5);print $9}'
|
||||
echo "\n==========================================================================="
|
||||
|
||||
# hope you have python3 installed :)
|
||||
python3 -m http.server 9876 --directory data
|
||||
@@ -0,0 +1,296 @@
|
||||
//! This file is a fuzz-test for the wasm-split engine to ensure that it works as expected.
|
||||
//! The docsite is a better target for this, but we try to boil down the complexity into this small
|
||||
//! test file.
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use futures::AsyncReadExt;
|
||||
use js_sys::Date;
|
||||
use std::pin::Pin;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_split::lazy_loader;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(|| {
|
||||
rsx! {
|
||||
Router::<Route> {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Routable, PartialEq, Eq, Debug, Clone)]
|
||||
enum Route {
|
||||
#[layout(Nav)]
|
||||
#[route("/")]
|
||||
Home,
|
||||
#[route("/child")]
|
||||
ChildSplit,
|
||||
}
|
||||
|
||||
fn Nav() -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
Link { id: "link-home", to: Route::Home, "Home" }
|
||||
Link { id: "link-child", to: Route::ChildSplit, "Child" }
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) static GLOBAL_COUNTER: GlobalSignal<usize> = Signal::global(|| 0);
|
||||
|
||||
fn Home() -> Element {
|
||||
let mut count = use_signal(|| 1);
|
||||
let mut res = use_signal(|| "hello".to_string());
|
||||
|
||||
rsx! {
|
||||
h1 { "Hello bundle split 456" }
|
||||
h3 { id: "counter-display", "Count: {count}" }
|
||||
h3 { id: "global-counter", "Global Counter: {GLOBAL_COUNTER}" }
|
||||
button {
|
||||
id: "increment-counter",
|
||||
onclick: move |_| count += 1,
|
||||
"Click me"
|
||||
}
|
||||
button {
|
||||
id: "increment-counter-global",
|
||||
onclick: move |_| *GLOBAL_COUNTER.write() += 1,
|
||||
"Click me"
|
||||
}
|
||||
button {
|
||||
id: "add-body-text",
|
||||
onclick: move |_| add_body_text(),
|
||||
"Add body text"
|
||||
}
|
||||
button {
|
||||
id: "add-body-element",
|
||||
onclick: move |_| async move {
|
||||
add_body_element().await;
|
||||
count += 1;
|
||||
},
|
||||
"Add body element"
|
||||
}
|
||||
button {
|
||||
id: "gzip-it",
|
||||
onclick: move |_| async move {
|
||||
gzip_it().await;
|
||||
},
|
||||
"GZIP it"
|
||||
}
|
||||
button {
|
||||
id: "brotli-it",
|
||||
onclick: move |_| async move {
|
||||
brotli_it(&[0u8; 10]).await;
|
||||
},
|
||||
"Brotli It"
|
||||
}
|
||||
button {
|
||||
id: "make-request",
|
||||
onclick: move |_| async move {
|
||||
let res_ = make_request().await.unwrap();
|
||||
res.set(res_);
|
||||
},
|
||||
"Make Request!"
|
||||
}
|
||||
button {
|
||||
id: "make-local-request",
|
||||
onclick: move |_| async move {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get("https://dog.ceo/api/breeds/image/random")
|
||||
.send()
|
||||
.await?;
|
||||
let body = response.text().await?;
|
||||
*res.write() = body;
|
||||
Ok(())
|
||||
},
|
||||
"local request"
|
||||
}
|
||||
LazyComponent {
|
||||
abc: 0
|
||||
}
|
||||
div { "Response: {res}" }
|
||||
div { id: "output-box" }
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_split::wasm_split(one)]
|
||||
async fn add_body_text() {
|
||||
let window = web_sys::window().unwrap_throw();
|
||||
let document = window.document().unwrap_throw();
|
||||
let output = document.create_text_node("Rendered!");
|
||||
let output_box = document.get_element_by_id("output-box").unwrap_throw();
|
||||
output_box.append_child(&output).unwrap_throw();
|
||||
*GLOBAL_COUNTER.write() += 1;
|
||||
}
|
||||
|
||||
#[wasm_split::wasm_split(two)]
|
||||
async fn add_body_element() {
|
||||
let window = web_sys::window().unwrap_throw();
|
||||
let document = window.document().unwrap_throw();
|
||||
let output = document.create_element("div").unwrap_throw();
|
||||
output.set_text_content(Some("Some inner div"));
|
||||
let output_box = document.get_element_by_id("output-box").unwrap_throw();
|
||||
output_box.append_child(&output).unwrap_throw();
|
||||
|
||||
dioxus::prelude::queue_effect(move || {
|
||||
web_sys::console::log_1(&"add body async internal!".into());
|
||||
*GLOBAL_COUNTER.write() += 2;
|
||||
});
|
||||
}
|
||||
|
||||
#[wasm_split::wasm_split(four)]
|
||||
async fn gzip_it() {
|
||||
static DATA: &[u8] = &[0u8; 10];
|
||||
let reader = Box::pin(futures::io::BufReader::new(DATA));
|
||||
let reader: Pin<Box<dyn futures::io::AsyncBufRead>> = reader;
|
||||
|
||||
dioxus::prelude::spawn(async move {
|
||||
let mut fut = Box::pin(async_compression::futures::bufread::GzipDecoder::new(
|
||||
reader,
|
||||
));
|
||||
if fut.read_to_end(&mut Vec::new()).await.is_err() {
|
||||
web_sys::console::log_1(&"error reading gzip".into());
|
||||
}
|
||||
*GLOBAL_COUNTER.write() += 3;
|
||||
|
||||
let res: Result<String, anyhow::Error> = Box::pin(async move {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get("https://dog.ceo/api/breeds/image/random")
|
||||
.send()
|
||||
.await?;
|
||||
let body = response.text().await?;
|
||||
Ok(body)
|
||||
})
|
||||
.await;
|
||||
|
||||
if res.is_err() {
|
||||
web_sys::console::log_1(&"error making request".into());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[wasm_split::wasm_split(three)]
|
||||
async fn brotli_it(data: &'static [u8]) {
|
||||
let reader = Box::pin(futures::io::BufReader::new(data));
|
||||
let reader: Pin<Box<dyn futures::io::AsyncBufRead>> = reader;
|
||||
|
||||
dioxus::prelude::spawn(async move {
|
||||
let mut fut = Box::pin(async_compression::futures::bufread::BrotliDecoder::new(
|
||||
reader,
|
||||
));
|
||||
if fut.read_to_end(&mut Vec::new()).await.is_err() {
|
||||
web_sys::console::log_1(&"error reading brotli".into());
|
||||
}
|
||||
*GLOBAL_COUNTER.write() += 4;
|
||||
});
|
||||
}
|
||||
|
||||
#[wasm_split::wasm_split(eleven)]
|
||||
async fn make_request() -> Result<String, anyhow::Error> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get("https://dog.ceo/api/breeds/image/random")
|
||||
.send()
|
||||
.await?;
|
||||
let body = response.text().await?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[component(lazy)]
|
||||
fn LazyComponent(abc: i32) -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
"This is a lazy component! {abc}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ChildSplit() -> Element {
|
||||
pub(crate) static DATE: GlobalSignal<Date> = Signal::global(Date::new_0);
|
||||
|
||||
static LOADER: wasm_split::LazyLoader<(), Element> =
|
||||
lazy_loader!(extern "five" fn InnerChild(props: ()) -> Element);
|
||||
|
||||
fn InnerChild(_props: ()) -> Element {
|
||||
static LOADER2: wasm_split::LazyLoader<Signal<String>, Element> =
|
||||
lazy_loader!(extern "fortytwo" fn InnerChild3(props: Signal<String>) -> Element);
|
||||
|
||||
fn InnerChild3(count: Signal<String>) -> Element {
|
||||
pub(crate) static ICONCHECK: Component<()> = |_| {
|
||||
rsx! {
|
||||
svg {
|
||||
class: "octicon octicon-check js-clipboard-check-icon d-inline-block d-none",
|
||||
fill: "rgb(26, 127, 55)",
|
||||
height: "24",
|
||||
version: "1.1",
|
||||
"aria_hidden": "true",
|
||||
width: "24",
|
||||
view_box: "0 0 16 16",
|
||||
"data_view_component": "true",
|
||||
path {
|
||||
d: "M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z",
|
||||
fill_rule: "evenodd",
|
||||
}
|
||||
}
|
||||
button {
|
||||
onclick: move |_| {
|
||||
*DATE.write() = Date::new_0();
|
||||
},
|
||||
"Update Date"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let now = DATE.read().clone();
|
||||
|
||||
rsx! {
|
||||
h1 { "Some other child" }
|
||||
h3 { "Global Counter: {GLOBAL_COUNTER}" }
|
||||
h3 { "Date: {now.to_date_string()}" }
|
||||
h3 { "count: {count}" }
|
||||
ICONCHECK {}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(module = "/src/stars.js")]
|
||||
extern "C" {
|
||||
pub(crate) fn get_stars(name: String) -> Option<usize>;
|
||||
pub(crate) fn set_stars(name: String, stars: usize);
|
||||
}
|
||||
|
||||
let num = get_stars("stars".to_string()).unwrap_or(0);
|
||||
|
||||
use_resource(|| async move { LOADER2.load().await }).suspend()?;
|
||||
let mut count = use_signal(|| "hello".to_string());
|
||||
|
||||
let fp = LOADER2.call(count).unwrap();
|
||||
|
||||
rsx! {
|
||||
h1 { "Some huge child?" }
|
||||
p { "Stars: {num}" }
|
||||
button {
|
||||
onclick: move |_| {
|
||||
set_stars("stars".to_string(), num + 1);
|
||||
dioxus::prelude::needs_update();
|
||||
},
|
||||
"Add Star"
|
||||
}
|
||||
{fp}
|
||||
h3 { id: "nested-child-count", "Count: {count}" }
|
||||
button {
|
||||
id: "nested-child-add-world",
|
||||
onclick: move |_| {
|
||||
*count.write() += " world";
|
||||
},
|
||||
"Add World"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use_resource(|| async move { LOADER.load().await }).suspend()?;
|
||||
|
||||
LOADER.call(()).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Handle caching of github stars
|
||||
// This file is part of the fuzz test to prove we can handle linked js files
|
||||
|
||||
// Two days
|
||||
const STAR_EXPIRE_TIME = 172800000;
|
||||
|
||||
export function get_stars(name) {
|
||||
let item = localStorage.getItem(name);
|
||||
let data = JSON.parse(item);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data.expires <= Date.now()) {
|
||||
localStorage.removeItem(name);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.stars;
|
||||
}
|
||||
|
||||
export function set_stars(name, value) {
|
||||
let expires = Date.now() + STAR_EXPIRE_TIME;
|
||||
let data = { stars: value, expires };
|
||||
|
||||
let converted = JSON.stringify(data);
|
||||
localStorage.setItem(name, converted);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
test("wasm-split page is functional", async ({ page }) => {
|
||||
// Wait for the dev server to load
|
||||
await page.goto("http://localhost:8001");
|
||||
|
||||
// Make sure the local button works - no broken wasm
|
||||
const counter = page.locator("#counter-display");
|
||||
await expect(counter).toContainText("Count: 1");
|
||||
await page.locator("#increment-counter").click();
|
||||
await expect(counter).toContainText("Count: 2");
|
||||
|
||||
// Make sure the global button works - no broken wasm
|
||||
const counterGlobal = page.locator("#global-counter");
|
||||
await expect(counterGlobal).toContainText("Global Counter: 0");
|
||||
await page.locator("#increment-counter-global").click();
|
||||
await expect(counterGlobal).toContainText("Global Counter: 1");
|
||||
|
||||
// Fire one of the wasm modules to load. Should update the counter and add some text
|
||||
const addBodyTextButton = page.locator("#add-body-text");
|
||||
await addBodyTextButton.click();
|
||||
await expect(counterGlobal).toContainText("Global Counter: 2");
|
||||
const outputBox = page.locator("#output-box");
|
||||
await expect(outputBox).toContainText("Rendered!");
|
||||
|
||||
// The other wasm module
|
||||
const addBodyElementButton = page.locator("#add-body-element");
|
||||
await addBodyElementButton.click();
|
||||
await expect(counterGlobal).toContainText("Global Counter: 4");
|
||||
await expect(outputBox).toContainText("Some inner div");
|
||||
|
||||
// Load the gzip and brotli modules
|
||||
const gzipButton = page.locator("#gzip-it");
|
||||
await gzipButton.click();
|
||||
await expect(counterGlobal).toContainText("Global Counter: 7");
|
||||
const brotliButton = page.locator("#brotli-it");
|
||||
await brotliButton.click();
|
||||
await expect(counterGlobal).toContainText("Global Counter: 11");
|
||||
|
||||
// Ignore the requests in CI
|
||||
// Load the other router module
|
||||
const childRouteButton = page.locator("#link-child");
|
||||
await childRouteButton.click();
|
||||
const nestedChildCounter = page.locator("#nested-child-count");
|
||||
await expect(nestedChildCounter).toContainText("Count: hello");
|
||||
await page.locator("#nested-child-add-world").click();
|
||||
await expect(nestedChildCounter).toContainText("Count: hello world");
|
||||
});
|
||||
Reference in new issue
Block a user