Compare commits

..

2 Commits

Author SHA1 Message Date
Greg Johnston
d5ad8f5ae4 fix: don't disable non-reactive access warnings at component body level 2023-07-03 16:13:45 -04:00
Greg Johnston
6b3e9cf85f fix: improve diagnostics for untracked reads on create_slice 2023-07-03 16:09:32 -04:00
78 changed files with 1133 additions and 1311 deletions

View File

@@ -26,22 +26,22 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.4.2"
version = "0.4.0"
[workspace.dependencies]
leptos = { path = "./leptos", version = "0.4.2" }
leptos_dom = { path = "./leptos_dom", version = "0.4.2" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.2" }
leptos_macro = { path = "./leptos_macro", version = "0.4.2" }
leptos_reactive = { path = "./leptos_reactive", version = "0.4.2" }
leptos_server = { path = "./leptos_server", version = "0.4.2" }
server_fn = { path = "./server_fn", version = "0.4.2" }
server_fn_macro = { path = "./server_fn_macro", version = "0.4.2" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.2" }
leptos_config = { path = "./leptos_config", version = "0.4.2" }
leptos_router = { path = "./router", version = "0.4.2" }
leptos_meta = { path = "./meta", version = "0.4.2" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.2" }
leptos = { path = "./leptos", version = "0.4.0" }
leptos_dom = { path = "./leptos_dom", version = "0.4.0" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.0" }
leptos_macro = { path = "./leptos_macro", version = "0.4.0" }
leptos_reactive = { path = "./leptos_reactive", version = "0.4.0" }
leptos_server = { path = "./leptos_server", version = "0.4.0" }
server_fn = { path = "./server_fn", version = "0.4.0" }
server_fn_macro = { path = "./server_fn_macro", version = "0.4.0" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.0" }
leptos_config = { path = "./leptos_config", version = "0.4.0" }
leptos_router = { path = "./router", version = "0.4.0" }
leptos_meta = { path = "./meta", version = "0.4.0" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.0" }
[profile.release]
codegen-units = 1

View File

@@ -97,15 +97,24 @@ args = ["+nightly", "doc"]
cwd = "leptos_macro/example"
install_crate = false
[tasks.ci-examples]
[tasks.test-examples]
description = "Run all unit and web tests for examples"
cwd = "examples"
command = "cargo"
args = ["make", "ci-clean"]
args = ["make", "test-unit-and-web"]
[tasks.verify-examples]
description = "Run all quality checks and tests for examples"
env = { CLEAN_AFTER_VERIFY = "true" }
cwd = "examples"
command = "cargo"
args = ["make", "verify-flow"]
[tasks.clean-examples]
description = "Clean all example projects"
cwd = "examples"
command = "cargo"
args = ["make", "clean"]
args = ["make", "clean-all"]
[env]
RUSTFLAGS = ""

View File

@@ -20,27 +20,18 @@ Create a basic Rust binary project
cargo init leptos-tutorial
```
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
```bash
cargo add leptos --features=csr,nightly # or just csr if you're using stable Rust
```
> Using `nightly` Rust, and the `nightly` feature in Leptos enables the function-call syntax for signal getters and setters that is used in most of this book.
>
> To use `nightly` Rust, you can run
> We recommend using `nightly` Rust, as it enables [a few nice features](https://github.com/leptos-rs/leptos#nightly-note). To use `nightly` Rust with WebAssembly, you can run
>
> ```bash
> rustup toolchain install nightly
> rustup default nightly
> rustup target add wasm32-unknown-unknown
> ```
>
> If youd rather use stable Rust with Leptos, you can do that too. In the guide and examples, youll just use the [`ReadSignal::get()`](https://docs.rs/leptos/latest/leptos/struct.ReadSignal.html#impl-SignalGet%3CT%3E-for-ReadSignal%3CT%3E) and [`WriteSignal::set()`](https://docs.rs/leptos/latest/leptos/struct.WriteSignal.html#impl-SignalGet%3CT%3E-for-ReadSignal%3CT%3E) methods instead of calling signal getters and setters as functions.
Make sure you've added the `wasm32-unknown-unknown` target do that Rust can compile your code to WebAssembly to run in the browser.
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
```bash
rustup target add wasm32-unknown-unknown
cargo add leptos --features=csr,nightly # or just csr if you're using stable Rust
```
Create a simple `index.html` in the root of the `leptos-tutorial` directory

View File

@@ -0,0 +1 @@
# Responding to Changes with create_effect

View File

@@ -23,6 +23,7 @@
- [Transition](./async/12_transition.md)
- [Actions](./async/13_actions.md)
- [Interlude: Projecting Children](./interlude_projecting_children.md)
- [Responding to Changes with `create_effect`](./14_create_effect.md)
- [Global State Management](./15_global_state.md)
- [Router](./router/README.md)
- [Defining `<Routes/>`](./router/16_routes.md)

View File

@@ -124,7 +124,7 @@ You can go even deeper. Say you want to have tabs for each contacts address,
## `<Outlet/>`
Parent routes do not automatically render their nested routes. After all, they are just components; they dont know exactly where they should render their children, and “just stick it at the end of the parent component” is not a great answer.
Parent routes do not automatically render their nested routes. After all, they are just components; they dont know exactly where they should render their children, and “just stick at at the end of the parent component” is not a great answer.
Instead, you tell a parent component where to render any nested components with an `<Outlet/>` component. The `<Outlet/>` simply renders one of two things:

View File

@@ -70,9 +70,9 @@ let id = move || {
This can get a little messy: deriving a signal that wraps an `Option<_>` or `Result<_>` can involve a couple steps. But its worth doing this for two reasons:
1. Its correct, i.e., it forces you to consider the cases, “What if the user doesnt pass a value for this query field? What if they pass an invalid value?”
2. Its performant. Specifically, when you navigate between different paths that match the same `<Route/>` with only params or the query changing, you can get fine-grained updates to different parts of your app without rerendering. For example, navigating between different contacts in our contact-list example does a targeted update to the name field (and eventually contact info) without needing to replace or rerender the wrapping `<Contact/>`. This is what fine-grained reactivity is for.
2. Its performant. Specifically, when you navigate between different paths that match the same `<Route/>` with only params or the query changing, you can get fine-grained updates to different parts of your app without rerendering. For example, navigating between different contacts in our contact-list example does a targeted update to the name field (and eventually contact info) without needing to replacing or rerender the wrapping `<Contact/>`. This is what fine-grained reactivity is for.
> This is the same example from the previous section. The router is such an integrated system that it makes sense to provide a single example highlighting multiple features, even if we havent explained them all yet.
> This is the same example from the previous section. The router is such an integrated system that it makes sense to provide a single example highlighting multiple features, even if we havent explain them all yet.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-fy4tjv?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D)

View File

@@ -1,12 +1,12 @@
# The `<Form/>` Component
Links and forms sometimes seem completely unrelated. But, in fact, they work in very similar ways.
Links and forms sometimes seem completely unrelated. But in fact, they work in very similar ways.
In plain HTML, there are three ways to navigate to another page:
1. An `<a>` element that links to another page: Navigates to the URL in its `href` attribute with the `GET` HTTP method.
2. A `<form method="GET">`: Navigates to the URL in its `action` attribute with the `GET` HTTP method and the form data from its inputs encoded in the URL query string.
3. A `<form method="POST">`: Navigates to the URL in its `action` attribute with the `POST` HTTP method and the form data from its inputs encoded in the body of the request.
1. An `<a>` element that links to another page. Navigates to the URL in its `href` attribute with the `GET` HTTP method.
2. A `<form method="GET">`. Navigates to the URL in its `action` attribute with the `GET` HTTP method and the form data from its inputs encoded in the URL query string.
3. A `<form method="POST">`. Navigates to the URL in its `action` attribute with the `POST` HTTP method and the form data from its inputs encoded in the body of the request.
Since we have a client-side router, we can do client-side link navigations without reloading the page, i.e., without a full round-trip to the server and back. It makes sense that we can do client-side form navigations in the same way.

View File

@@ -198,7 +198,7 @@ let (value, set_value) = create_signal(cx, 0);
view! { cx,
<Show
when=move || { value() > 5 }
when=move || value() > 5
fallback=|cx| view! { cx, <Small/> }
>
<Big/>

View File

@@ -19,7 +19,7 @@ fn NumericInput(cx: Scope) -> impl IntoView {
view! { cx,
<label>
"Type a number (or not!)"
<input on:input=on_input/>
<input type="number" on:input=on_input/>
<p>
"You entered "
<strong>{value}</strong>
@@ -69,7 +69,7 @@ fn NumericInput(cx: Scope) -> impl IntoView {
<h1>"Error Handling"</h1>
<label>
"Type a number (or something that's not a number!)"
<input on:input=on_input/>
<input type="number" on:input=on_input/>
<ErrorBoundary
// the fallback receives a signal containing current errors
fallback=|cx, errors| view! { cx,

View File

@@ -117,7 +117,7 @@ pub fn App(cx: Scope) -> impl IntoView {
#[component]
pub fn ButtonC(cx: Scope) -> impl IntoView {
pub fn ButtonC<F>(cx: Scope) -> impl IntoView {
view! { cx,
<button>"Toggle"</button>
}

View File

@@ -1,6 +1,5 @@
[tasks.integration-test]
dependencies = ["cargo-leptos-e2e"]
[tasks.test-e2e]
dependencies = ["setup-node", "cargo-leptos-e2e"]
[tasks.cargo-leptos-e2e]
command = "cargo"
args = ["leptos", "end-to-end"]
[tasks.clean-all]
dependencies = ["clean-cargo", "clean-node_modules", "clean-playwright"]

View File

@@ -1,28 +0,0 @@
[tasks.clean]
dependencies = [
"clean-cargo",
"clean-trunk",
"clean-node_modules",
"clean-playwright",
]
[tasks.clean-cargo]
command = "cargo"
args = ["clean"]
[tasks.clean-trunk]
command = "trunk"
args = ["clean"]
[tasks.clean-node_modules]
script = '''
project_dir=${PWD##*/}
if [ "$project_dir" != "todomvc" ]; then
find . -type d -name node_modules | xargs rm -rf
fi
'''
[tasks.clean-playwright]
script = '''
find . -name playwright-report -name playwright -name test-results | xargs rm -rf
'''

View File

@@ -0,0 +1,98 @@
[tasks.pre-clippy]
env = { CARGO_MAKE_CLIPPY_ARGS = "--all-targets --all-features -- -D warnings" }
[tasks.check-style]
description = "Check for style violations"
dependencies = ["check-format-flow", "clippy-flow"]
[tasks.check-format]
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }
args = ["fmt", "--", "--check", "--config-path", "${LEPTOS_PROJECT_DIRECTORY}"]
[tasks.clean-cargo]
description = "Runs the cargo clean command."
category = "Cleanup"
command = "cargo"
args = ["clean"]
[tasks.clean-trunk]
description = "Runs the trunk clean command."
category = "Cleanup"
command = "trunk"
args = ["clean"]
[tasks.clean-node_modules]
description = "Delete all node_modules directories"
category = "Cleanup"
script = '''
find . -type d -name node_modules | xargs rm -rf
'''
[tasks.clean-playwright]
description = "Delete playwright directories"
category = "Cleanup"
script = '''
for pw_dir in $(find . -name playwright.config.ts | xargs dirname)
do
rm -rf $pw_dir/playwright-report pw_dir/playwright pw_dir/test-results
done
'''
[tasks.clean-all]
description = "Delete all temporary directories"
category = "Cleanup"
dependencies = ["clean-cargo"]
[tasks.test-wasm]
env = { CARGO_MAKE_WASM_TEST_ARGS = "--headless --chrome" }
command = "cargo"
args = ["make", "wasm-pack-test"]
[tasks.cargo-leptos-e2e]
description = "Runs end to end tests with cargo leptos"
command = "cargo"
args = ["leptos", "end-to-end"]
[tasks.setup-node]
description = "Install node dependencies and playwright browsers"
env = { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1" }
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
RED="\e[0;31m"
RESET="\e[0m"
project_dir=$CARGO_MAKE_WORKING_DIRECTORY
# Discover commands
if command -v pnpm; then
NODE_CMD=pnpm
PLAYWRIGHT_CMD=pnpm
elif command -v npm; then
NODE_CMD=npm
PLAYWRIGHT_CMD=npx
else
echo "${RED}${BOLD}ERROR${RESET} - pnpm or npm is required by this task"
exit 1
fi
# Install node dependencies
for node_path in $(find . -name package.json -not -path '*/node_modules/*')
do
node_dir=$(dirname $node_path)
echo Install node dependencies for $node_dir
cd $node_dir
${NODE_CMD} install
cd ${project_dir}
done
# Install playwright browsers
for pw_path in $(find . -name playwright.config.ts)
do
pw_dir=$(dirname $pw_path)
echo Install playwright browsers for $pw_dir
cd $pw_dir
${PLAYWRIGHT_CMD} playwright install
cd $project_dir
done
'''

View File

@@ -1,9 +0,0 @@
[tasks.pre-clippy]
env = { CARGO_MAKE_CLIPPY_ARGS = "--all-targets --all-features -- -D warnings" }
[tasks.check-style]
dependencies = ["check-format-flow", "clippy-flow"]
[tasks.check-format]
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }
args = ["fmt", "--", "--check", "--config-path", "${LEPTOS_PROJECT_DIRECTORY}"]

View File

@@ -1,32 +1,35 @@
extend = [
{ path = "../cargo-make/clean.toml" },
{ path = "../cargo-make/lint.toml" },
{ path = "../cargo-make/node.toml" },
]
# CI Stages
extend = [{ path = "../cargo-make/common.toml" }]
[tasks.ci]
dependencies = ["prepare", "lint", "build", "test-flow", "integration-test"]
[tasks.ci-clean]
dependencies = ["ci", "clean"]
[tasks.prepare]
dependencies = ["setup-node"]
[tasks.lint]
dependencies = ["check-style"]
[tasks.integration-test]
# ALIASES
alias = "verify-flow"
[tasks.verify-flow]
alias = "ci"
description = "Provides pre and post hooks for verify"
dependencies = ["pre-verify", "verify", "post-verify"]
[tasks.t]
dependencies = ["test-flow"]
[tasks.verify]
description = "Run all quality checks and tests"
dependencies = ["check-style", "test-unit-and-e2e"]
[tasks.it]
alias = "integration-test"
[tasks.test-unit-and-e2e]
description = "Run all unit and e2e tests"
dependencies = ["test-flow", "test-e2e-flow"]
[tasks.pre-verify]
[tasks.post-verify]
dependencies = ["maybe-clean-all"]
[tasks.maybe-clean-all]
description = "Used to clean up locally after call to verify-examples"
condition = { env_true = ["CLEAN_AFTER_VERIFY"] }
[tasks.test-e2e-flow]
description = "Provides pre and post hooks for test-e2e"
dependencies = ["pre-test-e2e", "test-e2e", "post-test-e2e"]
[tasks.pre-test-e2e]
[tasks.test-e2e]
[tasks.post-test-e2e]

View File

@@ -1,43 +0,0 @@
[tasks.setup-node]
description = "Install node dependencies and playwright browsers"
env = { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1" }
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
RED="\e[0;31m"
RESET="\e[0m"
project_dir=$CARGO_MAKE_WORKING_DIRECTORY
# Discover commands
if command -v pnpm; then
NODE_CMD=pnpm
PLAYWRIGHT_CMD=pnpm
elif command -v npm; then
NODE_CMD=npm
PLAYWRIGHT_CMD=npx
else
echo "${RED}${BOLD}ERROR${RESET} - pnpm or npm is required by this task"
exit 1
fi
# Install node dependencies
for node_path in $(find . -name package.json -not -path '*/node_modules/*')
do
node_dir=$(dirname $node_path)
echo Install node dependencies for $node_dir
cd $node_dir
${NODE_CMD} install
cd ${project_dir}
done
# Install playwright browsers
for pw_path in $(find . -name playwright.config.ts)
do
pw_dir=$(dirname $pw_path)
echo Install playwright browsers for $pw_dir
cd $pw_dir
${PLAYWRIGHT_CMD} playwright install
cd $project_dir
done
'''

View File

@@ -1,4 +1,7 @@
extend = [{ path = "../cargo-make/playwright.toml" }]
[tasks.integration-test]
dependencies = ["test-playwright-autostart"]
[tasks.test-e2e]
dependencies = ["setup-node", "test-playwright-autostart"]
[tasks.clean-all]
dependencies = ["clean-cargo", "clean-node_modules", "clean-playwright"]

View File

@@ -1,8 +1,22 @@
[tasks.clean-playwright]
description = "Delete playwright directories"
category = "Cleanup"
script = '''
for pw_dir in $(find . -name playwright.config.ts | xargs dirname)
do
rm -rf $pw_dir/playwright-report pw_dir/playwright pw_dir/test-results
done
'''
[tasks.test-playwright-autostart]
description = "Run playwright test with server autostart"
category = "Test"
command = "npm"
args = ["run", "e2e:auto-start"]
[tasks.test-playwright]
description = "Run playwright test"
category = "Test"
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
@@ -32,6 +46,8 @@ done
'''
[tasks.test-playwright-ui]
description = "Run playwright test --ui"
category = "Test"
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
@@ -61,6 +77,8 @@ done
'''
[tasks.test-playwright-report]
description = "Run playwright show-report"
category = "Test"
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"

View File

@@ -2,9 +2,13 @@
command = "trunk"
args = ["build"]
[tasks.clean-trunk]
command = "trunk"
args = ["clean"]
[tasks.start-trunk]
command = "trunk"
args = ["serve", "${@}"]
args = ["serve", "--open"]
[tasks.stop-trunk]
script = '''

View File

@@ -5,7 +5,5 @@ condition = { env_true = ["RUN_CARGO_TEST"] }
[tasks.post-test]
dependencies = ["test-wasm"]
[tasks.test-wasm]
env = { CARGO_MAKE_WASM_TEST_ARGS = "--headless --chrome" }
command = "cargo"
args = ["make", "wasm-pack-test"]
[tasks.clean-all]
dependencies = ["clean-cargo", "clean-trunk"]

View File

@@ -25,7 +25,7 @@ leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
log = "0.4"
gloo-net = { git = "https://github.com/rustwasm/gloo" }
wasm-bindgen = "=0.2.87"
wasm-bindgen = "=0.2.86"
serde = { version = "1", features = ["derive"] }
[features]

View File

@@ -67,10 +67,24 @@ pub fn Counters(cx: Scope) -> impl IntoView {
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<main>
<Routes>
<Route path="" view=Counter/>
<Route path="form" view=FormCounter/>
<Route path="multi" view=MultiuserCounter/>
<Route path="multi" view=NotFound/>
<Route
path=""
view=|cx| {
view! { cx, <Counter/> }
}
/>
<Route
path="form"
view=|cx| {
view! { cx, <FormCounter/> }
}
/>
<Route
path="multi"
view=|cx| {
view! { cx, <MultiuserCounter/> }
}
/>
</Routes>
</main>
</Router>
@@ -161,9 +175,13 @@ pub fn FormCounter(cx: Scope) -> impl IntoView {
"This counter uses forms to set the value on the server. When progressively enhanced, it should behave identically to the “Simple Counter.”"
</p>
<div>
// calling a server function is the same as POSTing to its API URL
// so we can just do that with a form and button
<ActionForm action=clear>
<input type="submit" value="Clear"/>
</ActionForm>
// We can submit named arguments to the server functions
// by including them as input values with the same name
<ActionForm action=adjust>
<input type="hidden" name="delta" value="-1"/>
<input type="hidden" name="msg" value="form value down"/>
@@ -238,14 +256,3 @@ pub fn MultiuserCounter(cx: Scope) -> impl IntoView {
</div>
}
}
#[component]
fn NotFound(cx: Scope) -> impl IntoView {
#[cfg(feature = "ssr")]
{
let resp = expect_context::<leptos_actix::ResponseOptions>(cx);
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
}
view! { cx, <h1>"Not Found"</h1> }
}

View File

@@ -52,36 +52,15 @@ cfg_if! {
App::new()
.service(counter_events)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
// serve JS/WASM/CSS from `pkg`
.service(Files::new("/pkg", format!("{site_root}/pkg")))
// serve other assets from the `assets` directory
.service(Files::new("/assets", site_root))
// serve the favicon from /favicon.ico
.service(favicon)
.leptos_routes(
leptos_options.to_owned(),
routes.to_owned(),
Counters,
)
.app_data(web::Data::new(leptos_options.to_owned()))
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <Counters/> })
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()
.await
}
#[actix_web::get("favicon.ico")]
async fn favicon(
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
) -> actix_web::Result<actix_files::NamedFile> {
let leptos_options = leptos_options.into_inner();
let site_root = &leptos_options.site_root;
Ok(actix_files::NamedFile::open(format!(
"{site_root}/favicon.ico"
))?)
}
}
// client-only main for Trunk
else {

View File

@@ -5,23 +5,10 @@ edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos_meta = { path = "../../meta", features = ["csr"] }
log = "0.4"
console_log = "1"
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen = "0.2.87"
wasm-bindgen-test = "0.3.37"
pretty_assertions = "1.3.0"
wasm-bindgen-test = "0.3.0"
[dev-dependencies.web-sys]
features = [
"Event",
"EventInit",
"EventTarget",
"HtmlElement",
"HtmlInputElement",
"XPathResult",
]
version = "0.3.64"

View File

@@ -1,6 +1,5 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/wasm-test.toml" },
{ path = "../cargo-make/trunk_server.toml" },
{ path = "../cargo-make/playwright-test.toml" },
]

View File

@@ -14,6 +14,7 @@ test.describe("Add 1000 Counters", () => {
await ui.addOneThousandCounters();
await ui.addOneThousandCounters();
await expect(ui.total).toHaveText("0");
await expect(ui.counters).toHaveText("3000");
});
});

View File

@@ -10,6 +10,7 @@ test.describe("Add Counter", () => {
await ui.addCounter();
await ui.addCounter();
await expect(ui.total).toHaveText("0");
await expect(ui.counters).toHaveText("3");
});
});

View File

@@ -12,5 +12,6 @@ test.describe("Decrement Count", () => {
await ui.decrementCount();
await expect(ui.total).toHaveText("-3");
await expect(ui.counters).toHaveText("1");
});
});

View File

@@ -26,5 +26,6 @@ test.describe("Enter Count", () => {
await ui.enterCount("50", 1);
await expect(ui.total).toHaveText("250");
await expect(ui.counters).toHaveText("3");
});
});

View File

@@ -12,5 +12,6 @@ test.describe("Increment Count", () => {
await ui.incrementCount();
await expect(ui.total).toHaveText("3");
await expect(ui.counters).toHaveText("1");
});
});

View File

@@ -12,6 +12,7 @@ test.describe("Remove Counter", () => {
await ui.removeCounter(1);
await expect(ui.total).toHaveText("0");
await expect(ui.counters).toHaveText("2");
});
});

View File

@@ -1,109 +0,0 @@
use leptos::*;
use leptos_meta::*;
const MANY_COUNTERS: usize = 1000;
type CounterHolder = Vec<(usize, (ReadSignal<i32>, WriteSignal<i32>))>;
#[derive(Copy, Clone)]
struct CounterUpdater {
set_counters: WriteSignal<CounterHolder>,
}
#[component]
pub fn Counters(cx: Scope) -> impl IntoView {
let (next_counter_id, set_next_counter_id) = create_signal(cx, 0);
let (counters, set_counters) = create_signal::<CounterHolder>(cx, vec![]);
provide_context(cx, CounterUpdater { set_counters });
provide_meta_context(cx);
let add_counter = move |_| {
let id = next_counter_id.get();
let sig = create_signal(cx, 0);
set_counters.update(move |counters| counters.push((id, sig)));
set_next_counter_id.update(|id| *id += 1);
};
let add_many_counters = move |_| {
let next_id = next_counter_id.get();
let new_counters = (next_id..next_id + MANY_COUNTERS).map(|id| {
let signal = create_signal(cx, 0);
(id, signal)
});
set_counters.update(move |counters| counters.extend(new_counters));
set_next_counter_id.update(|id| *id += MANY_COUNTERS);
};
let clear_counters = move |_| {
set_counters.update(|counters| counters.clear());
};
view! { cx,
<Title text="Counters (Stable)" />
<div>
<button on:click=add_counter>
"Add Counter"
</button>
<button on:click=add_many_counters>
{format!("Add {MANY_COUNTERS} Counters")}
</button>
<button on:click=clear_counters>
"Clear Counters"
</button>
<p>
"Total: "
<span data-testid="total">{move ||
counters.get()
.iter()
.map(|(_, (count, _))| count.get())
.sum::<i32>()
.to_string()
}</span>
" from "
<span data-testid="counters">{move || counters.with(|counters| counters.len()).to_string()}</span>
" counters."
</p>
<ul>
<For
each={move || counters.get()}
key={|counter| counter.0}
view=move |cx, (id, (value, set_value))| {
view! {
cx,
<Counter id value set_value/>
}
}
/>
</ul>
</div>
}
}
#[component]
fn Counter(
cx: Scope,
id: usize,
value: ReadSignal<i32>,
set_value: WriteSignal<i32>,
) -> impl IntoView {
let CounterUpdater { set_counters } = use_context(cx).unwrap();
let input = move |ev| {
set_value
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
};
view! { cx,
<li>
<button data-testid="decrement_count" on:click=move |_| set_value.update(move |value| *value -= 1)>"-1"</button>
<input data-testid="counter_input" type="text"
prop:value={move || value.get().to_string()}
on:input=input
/>
<span>{value}</span>
<button data-testid="increment_count" on:click=move |_| set_value.update(move |value| *value += 1)>"+1"</button>
<button data-testid="remove_counter" on:click=move |_| set_counters.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))>"x"</button>
</li>
}
}

View File

@@ -1,4 +1,3 @@
use counters_stable::Counters;
use leptos::*;
fn main() {
@@ -6,3 +5,108 @@ fn main() {
console_error_panic_hook::set_once();
mount_to_body(|cx| view! { cx, <Counters/> })
}
const MANY_COUNTERS: usize = 1000;
type CounterHolder = Vec<(usize, (ReadSignal<i32>, WriteSignal<i32>))>;
#[derive(Copy, Clone)]
struct CounterUpdater {
set_counters: WriteSignal<CounterHolder>,
}
#[component]
pub fn Counters(cx: Scope) -> impl IntoView {
let (next_counter_id, set_next_counter_id) = create_signal(cx, 0);
let (counters, set_counters) = create_signal::<CounterHolder>(cx, vec![]);
provide_context(cx, CounterUpdater { set_counters });
let add_counter = move |_| {
let id = next_counter_id.get();
let sig = create_signal(cx, 0);
set_counters.update(move |counters| counters.push((id, sig)));
set_next_counter_id.update(|id| *id += 1);
};
let add_many_counters = move |_| {
let next_id = next_counter_id.get();
let new_counters = (next_id..next_id + MANY_COUNTERS).map(|id| {
let signal = create_signal(cx, 0);
(id, signal)
});
set_counters.update(move |counters| counters.extend(new_counters));
set_next_counter_id.update(|id| *id += MANY_COUNTERS);
};
let clear_counters = move |_| {
set_counters.update(|counters| counters.clear());
};
view! { cx,
<div>
<button on:click=add_counter>
"Add Counter"
</button>
<button on:click=add_many_counters>
{format!("Add {MANY_COUNTERS} Counters")}
</button>
<button on:click=clear_counters>
"Clear Counters"
</button>
<p>
"Total: "
<span data-testid="total">{move ||
counters.get()
.iter()
.map(|(_, (count, _))| count.get())
.sum::<i32>()
.to_string()
}</span>
" from "
<span data-testid="counters">{move || counters.with(|counters| counters.len()).to_string()}</span>
" counters."
</p>
<ul>
<For
each={move || counters.get()}
key={|counter| counter.0}
view=move |cx, (id, (value, set_value))| {
view! {
cx,
<Counter id value set_value/>
}
}
/>
</ul>
</div>
}
}
#[component]
fn Counter(
cx: Scope,
id: usize,
value: ReadSignal<i32>,
set_value: WriteSignal<i32>,
) -> impl IntoView {
let CounterUpdater { set_counters } = use_context(cx).unwrap();
let input = move |ev| {
set_value
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
};
view! { cx,
<li>
<button id="decrement_count" on:click=move |_| set_value.update(move |value| *value -= 1)>"-1"</button>
<input type="text"
prop:value={move || value.get().to_string()}
on:input=input
/>
<span>{value}</span>
<button id="increment_count" on:click=move |_| set_value.update(move |value| *value += 1)>"+1"</button>
<button on:click=move |_| set_counters.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))>"x"</button>
</li>
}
}

View File

@@ -1,17 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_number_of_counters() {
// Given
ui::view_counters();
// When
ui::add_1k_counters();
ui::add_1k_counters();
ui::add_1k_counters();
// Then
assert_eq!(ui::counters(), 3000);
}

View File

@@ -1,17 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_number_of_counters() {
// Given
ui::view_counters();
// When
ui::add_counter();
ui::add_counter();
ui::add_counter();
// Then
assert_eq!(ui::counters(), 3);
}

View File

@@ -1,19 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_reset_the_counts() {
// Given
ui::view_counters();
ui::add_counter();
ui::add_counter();
ui::add_counter();
// When
ui::clear_counters();
// Then
assert_eq!(ui::total(), 0);
assert_eq!(ui::counters(), 0);
}

View File

@@ -1,18 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_decrease_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
// When
ui::decrement_counter(1);
ui::decrement_counter(1);
ui::decrement_counter(1);
// Then
assert_eq!(ui::total(), -3);
}

View File

@@ -1,34 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
// When
ui::enter_count(1, 5);
// Then
assert_eq!(ui::total(), 5);
}
#[wasm_bindgen_test]
fn should_decrease_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
ui::add_counter();
ui::add_counter();
// When
ui::enter_count(1, 100);
ui::enter_count(2, 100);
ui::enter_count(3, 100);
ui::enter_count(1, 50);
// Then
assert_eq!(ui::total(), 250);
}

View File

@@ -1,112 +0,0 @@
use counters_stable::Counters;
use leptos::*;
use wasm_bindgen::JsCast;
use web_sys::{Element, Event, EventInit, HtmlElement, HtmlInputElement};
// Actions
pub fn add_1k_counters() {
find_by_text("Add 1000 Counters").click();
}
pub fn add_counter() {
find_by_text("Add Counter").click();
}
pub fn clear_counters() {
find_by_text("Clear Counters").click();
}
pub fn decrement_counter(index: u32) {
counter_html_element(index, "decrement_count").click();
}
pub fn enter_count(index: u32, count: i32) {
let input = counter_input_element(index, "counter_input");
input.set_value(count.to_string().as_str());
let mut event_init = EventInit::new();
event_init.bubbles(true);
let event = Event::new_with_event_init_dict("input", &event_init).unwrap();
input.dispatch_event(&event).unwrap();
}
pub fn increment_counter(index: u32) {
counter_html_element(index, "increment_count").click();
}
pub fn remove_counter(index: u32) {
counter_html_element(index, "remove_counter").click();
}
pub fn view_counters() {
remove_existing_counters();
mount_to_body(|cx| view! { cx, <Counters/> });
}
// Results
pub fn counters() -> i32 {
data_test_id("counters").parse::<i32>().unwrap()
}
pub fn title() -> String {
leptos::document().title()
}
pub fn total() -> i32 {
data_test_id("total").parse::<i32>().unwrap()
}
// Internal
fn counter_element(index: u32, text: &str) -> Element {
let selector =
format!("li:nth-child({}) [data-testid=\"{}\"]", index, text);
leptos::document()
.query_selector(&selector)
.unwrap()
.unwrap()
}
fn counter_html_element(index: u32, text: &str) -> HtmlElement {
counter_element(index, text)
.dyn_into::<HtmlElement>()
.unwrap()
}
fn counter_input_element(index: u32, text: &str) -> HtmlInputElement {
counter_element(index, text)
.dyn_into::<HtmlInputElement>()
.unwrap()
}
fn data_test_id(id: &str) -> String {
let selector = format!("[data-testid=\"{}\"]", id);
leptos::document()
.query_selector(&selector)
.unwrap()
.expect("counters not found")
.text_content()
.unwrap()
}
fn find_by_text(text: &str) -> HtmlElement {
let xpath = format!("//*[text()='{}']", text);
let document = leptos::document();
document
.evaluate(&xpath, &document)
.unwrap()
.iterate_next()
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
}
fn remove_existing_counters() {
if let Some(counter) =
leptos::document().query_selector("body div").unwrap()
{
counter.remove();
}
}

View File

@@ -1 +0,0 @@
pub mod counters_page;

View File

@@ -1,18 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
// When
ui::increment_counter(1);
ui::increment_counter(1);
ui::increment_counter(1);
// Then
assert_eq!(ui::total(), 3);
}

View File

@@ -1,16 +0,0 @@
use wasm_bindgen_test::*;
// Test Suites
pub mod add_1k_counters;
pub mod add_counter;
pub mod clear_counters;
pub mod decrement_counter;
pub mod enter_count;
pub mod increment_counter;
pub mod remove_counter;
pub mod view_counters;
pub mod fixtures;
pub use fixtures::*;
wasm_bindgen_test_configure!(run_in_browser);

View File

@@ -1,18 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_decrement_the_number_of_counters() {
// Given
ui::view_counters();
ui::add_counter();
ui::add_counter();
ui::add_counter();
// When
ui::remove_counter(2);
// Then
assert_eq!(ui::counters(), 2);
}

View File

@@ -1,22 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_see_the_initial_counts() {
// When
ui::view_counters();
// Then
assert_eq!(ui::total(), 0);
assert_eq!(ui::counters(), 0);
}
#[wasm_bindgen_test]
fn should_see_the_title() {
// When
ui::view_counters();
// Then
assert_eq!(ui::title(), "Counters (Stable)");
}

View File

@@ -11,7 +11,7 @@ pub fn App(cx: Scope) -> impl IntoView {
<h1>"Error Handling"</h1>
<label>
"Type a number (or something that's not a number!)"
<input on:input=on_input/>
<input type="number" on:input=on_input/>
// If an `Err(_) had been rendered inside the <ErrorBoundary/>,
// the fallback will be displayed. Otherwise, the children of the
// <ErrorBoundary/> will be displayed.

View File

@@ -14,6 +14,10 @@ cfg_if! {
async fn css() -> impl Responder {
actix_files::NamedFile::open_async("./style.css").await
}
#[get("/favicon.ico")]
async fn favicon() -> impl Responder {
actix_files::NamedFile::open_async("./target/site//favicon.ico").await
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
@@ -29,34 +33,17 @@ cfg_if! {
let site_root = &leptos_options.site_root;
App::new()
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.service(Files::new("/pkg", format!("{site_root}/pkg")))
.service(Files::new("/assets", site_root))
.service(favicon)
.service(css)
.leptos_routes(
leptos_options.to_owned(),
routes.to_owned(),
|cx| view! { cx, <App/> },
)
.app_data(web::Data::new(leptos_options.to_owned()))
.service(favicon)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <App/> })
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()
.await
}
#[actix_web::get("favicon.ico")]
async fn favicon(
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
) -> actix_web::Result<actix_files::NamedFile> {
let leptos_options = leptos_options.into_inner();
let site_root = &leptos_options.site_root;
Ok(actix_files::NamedFile::open(format!(
"{site_root}/favicon.ico"
))?)
}
} else {
fn main() {
use hackernews::{App};

View File

@@ -27,11 +27,6 @@ pub fn App(cx: Scope) -> impl IntoView {
view=Post
ssr=SsrMode::Async
/>
<Route
path="/*any"
view=NotFound
/>
</Routes>
</main>
</Router>
@@ -187,14 +182,3 @@ pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS.iter().find(|post| post.id == id).cloned())
}
#[component]
fn NotFound(cx: Scope) -> impl IntoView {
#[cfg(feature = "ssr")]
{
let resp = expect_context::<leptos_actix::ResponseOptions>(cx);
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
}
view! { cx, <h1>"Not Found"</h1> }
}

View File

@@ -24,11 +24,12 @@ async fn main() -> std::io::Result<()> {
App::new()
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.service(Files::new("/pkg", format!("{site_root}/pkg")))
.service(Files::new("/assets", site_root))
.service(favicon)
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
.app_data(web::Data::new(leptos_options.to_owned()))
.leptos_routes(
leptos_options.to_owned(),
routes.to_owned(),
|cx| view! { cx, <App/> },
)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
@@ -36,18 +37,6 @@ async fn main() -> std::io::Result<()> {
.await
}
#[cfg(feature = "ssr")]
#[actix_web::get("favicon.ico")]
async fn favicon(
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
) -> actix_web::Result<actix_files::NamedFile> {
let leptos_options = leptos_options.into_inner();
let site_root = &leptos_options.site_root;
Ok(actix_files::NamedFile::open(format!(
"{site_root}/favicon.ico"
))?)
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function

View File

@@ -30,25 +30,6 @@ npm install -D tailwindcss
If you'd rather not use `npm`, you can install the Tailwind binary [here](https://github.com/tailwindlabs/tailwindcss/releases).
## Adding Tailwind plugins
If you'd like to add [Tailwind plugins](https://tailwindcss.com/docs/plugins), such as [DaisyUI](https://daisyui.com/), you can do the following:
`npm install -D daisyui@latest`
Then add the plugin to your exports in `tailwind.config.js` :
```javascript
module.exports = {
//...
plugins: [require("daisyui")],
};
```
And re-run the following to generate the css:
`npx tailwindcss -i ./input.css -o ./style/output.css --watch`
## Setting up with VS Code and Additional Tools
If you're using VS Code, add the following to your `settings.json`

View File

@@ -47,32 +47,14 @@ cfg_if! {
App::new()
.service(css)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.service(Files::new("/pkg", format!("{site_root}/pkg")))
.service(Files::new("/assets", site_root))
.service(favicon)
.leptos_routes(
leptos_options.to_owned(),
routes.to_owned(),
TodoApp,
)
.app_data(web::Data::new(leptos_options.to_owned()))
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <TodoApp/> })
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(addr)?
.run()
.await
}
#[actix_web::get("favicon.ico")]
async fn favicon(
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
) -> actix_web::Result<actix_files::NamedFile> {
let leptos_options = leptos_options.into_inner();
let site_root = &leptos_options.site_root;
Ok(actix_files::NamedFile::open(format!(
"{site_root}/favicon.ico"
))?)
}
} else {
fn main() {
// no client-side main function

View File

@@ -92,8 +92,10 @@ pub fn TodoApp(cx: Scope) -> impl IntoView {
</header>
<main>
<Routes>
<Route path="" view=Todos/>
<Route path="/*any" view=NotFound/>
<Route path="" view=|cx| view! {
cx,
<Todos/>
}/>
</Routes>
</main>
</Router>
@@ -198,14 +200,3 @@ pub fn Todos(cx: Scope) -> impl IntoView {
</div>
}
}
#[component]
fn NotFound(cx: Scope) -> impl IntoView {
#[cfg(feature = "ssr")]
{
let resp = expect_context::<leptos_actix::ResponseOptions>(cx);
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
}
view! { cx, <h1>"Not Found"</h1> }
}

26
flake.lock generated
View File

@@ -38,11 +38,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1687898314,
"narHash": "sha256-B4BHon3uMXQw8ZdbwxRK1BmxVOGBV4viipKpGaIlGwk=",
"lastModified": 1681920287,
"narHash": "sha256-+/d6XQQfhhXVfqfLROJoqj3TuG38CAeoT6jO1g9r1k0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e18dc963075ed115afb3e312b64643bf8fd4b474",
"rev": "645bc49f34fa8eff95479f0345ff57e55b53437e",
"type": "github"
},
"original": {
@@ -52,6 +52,22 @@
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1681358109,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
@@ -62,9 +78,7 @@
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": [
"nixpkgs"
]
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1682043560,

View File

@@ -4,7 +4,6 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
@@ -23,8 +22,7 @@
openssl
pkg-config
cacert
mdbook
(rust-bin.selectLatestNightlyWith(toolchain: toolchain.default.override {
(rust-bin.selectLatestNightlyWith( toolchain: toolchain.default.override {
extensions= [ "rust-src" "rust-analyzer" ];
targets = [ "wasm32-unknown-unknown" ];
}))

View File

@@ -223,30 +223,26 @@ pub fn handle_server_fns_with_context(
let res_options =
use_context::<ResponseOptions>(cx).unwrap();
let mut res: HttpResponseBuilder =
HttpResponse::Ok();
let mut res: HttpResponseBuilder;
let res_parts = res_options.0.write();
// if accept_header isn't set to one of these, it's a form submit
// redirect back to the referrer if not redirect has been set
if accept_header != Some("application/json")
&& accept_header
!= Some("application/x-www-form-urlencoded")
&& accept_header != Some("application/cbor")
if accept_header == Some("application/json")
|| accept_header
== Some("application/x-www-form-urlencoded")
|| accept_header == Some("application/cbor")
{
// Location will already be set if redirect() has been used
let has_location_set =
res_parts.headers.get("Location").is_some();
if !has_location_set {
let referer = req
.headers()
.get("Referer")
.and_then(|value| value.to_str().ok())
.unwrap_or("/");
res = HttpResponse::SeeOther();
res.insert_header(("Location", referer))
.content_type("application/json");
}
res = HttpResponse::Ok();
}
// otherwise, it's probably a <form> submit or something: redirect back to the referrer
else {
let referer = req
.headers()
.get("Referer")
.and_then(|value| value.to_str().ok())
.unwrap_or("/");
res = HttpResponse::SeeOther();
res.insert_header(("Location", referer))
.content_type("application/json");
};
// Override StatusCode if it was set in a Resource or Element
if let Some(status) = res_parts.status {

View File

@@ -1276,24 +1276,18 @@ impl ExtractorHelper {
}
}
pub async fn extract<F, T, U, S>(
&self,
f: F,
s: S,
) -> Result<U, T::Rejection>
pub async fn extract<F, T, U>(&self, f: F) -> Result<U, T::Rejection>
where
S: Sized,
F: Extractor<T, U, S>,
T: std::fmt::Debug + Send + FromRequestParts<S> + 'static,
F: Extractor<T, U>,
T: std::fmt::Debug + Send + FromRequestParts<()> + 'static,
T::Rejection: std::fmt::Debug + Send + 'static,
{
let mut parts = self.parts.lock().await;
let data = T::from_request_parts(&mut parts, &s).await?;
let data = T::from_request_parts(&mut parts, &()).await?;
Ok(f.call(data).await)
}
}
/// Getting ExtractorHelper from a request will return an ExtractorHelper whose state is ().
impl<B> From<Request<B>> for ExtractorHelper {
fn from(req: Request<B>) -> Self {
// TODO provide body for extractors there, too?
@@ -1330,71 +1324,34 @@ impl<B> From<Request<B>> for ExtractorHelper {
#[tracing::instrument(level = "trace", fields(error), skip_all)]
pub async fn extract<T, U>(
cx: Scope,
f: impl Extractor<T, U, ()>,
f: impl Extractor<T, U>,
) -> Result<U, T::Rejection>
where
T: std::fmt::Debug + Send + FromRequestParts<()> + 'static,
T::Rejection: std::fmt::Debug + Send + 'static,
{
extract_with_state(cx, (), f).await
}
/// A helper to make it easier to use Axum extractors in server functions. This takes
/// a handler function and state as its arguments. The handler rules similar to Axum
/// [handlers](https://docs.rs/axum/latest/axum/extract/index.html#intro): it is an async function
/// whose arguments are “extractors.”
///
/// ```rust,ignore
/// #[server(QueryExtract, "/api")]
/// pub async fn query_extract(cx: Scope) -> Result<String, ServerFnError> {
/// use axum::{extract::Query, http::Method};
/// use leptos_axum::extract;
/// let state: ServerState = use_context::<crate::ServerState>(cx)
/// .ok_or(ServerFnError::ServerError("No server state".to_string()))?;
///
/// extract_with_state(cx, state, |method: Method, res: Query<MyQuery>| async move {
/// format!("{method:?} and {}", res.q)
/// },
/// )
/// .await
/// .map_err(|e| ServerFnError::ServerError("Could not extract method and query...".to_string()))
/// }
/// ```
#[tracing::instrument(level = "trace", fields(error), skip_all)]
pub async fn extract_with_state<T, U, S>(
cx: Scope,
state: S,
f: impl Extractor<T, U, S>,
) -> Result<U, T::Rejection>
where
S: Sized,
T: std::fmt::Debug + Send + FromRequestParts<S> + 'static,
T::Rejection: std::fmt::Debug + Send + 'static,
{
use_context::<ExtractorHelper>(cx)
.expect(
"should have had ExtractorHelper provided by the leptos_axum \
integration",
)
.extract(f, state)
.extract(f)
.await
}
pub trait Extractor<T, U, S>
pub trait Extractor<T, U>
where
S: Sized,
T: FromRequestParts<S>,
T: FromRequestParts<()>,
{
fn call(self, args: T) -> Pin<Box<dyn Future<Output = U>>>;
}
macro_rules! factory_tuple ({ $($param:ident)* } => {
impl<Func, Fut, U, S, $($param,)*> Extractor<($($param,)*), U, S> for Func
impl<Func, Fut, U, $($param,)*> Extractor<($($param,)*), U> for Func
where
$($param: FromRequestParts<S> + Send,)*
$($param: FromRequestParts<()> + Send,)*
Func: FnOnce($($param),*) -> Fut + 'static,
Fut: Future<Output = U> + 'static,
S: Sized + Send + Sync,
{
#[inline]
#[allow(non_snake_case)]

View File

@@ -87,10 +87,10 @@ pub fn html_parts_separated(
let output_name = &options.output_name;
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME at compile time
// Otherwise we need to add _bg because wasm_pack always does.
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
let mut wasm_output_name = output_name.clone();
if std::option_env!("LEPTOS_OUTPUT_NAME").is_none() {
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
wasm_output_name.push_str("_bg");
}

View File

@@ -289,4 +289,4 @@ fn None(cx: Scope) -> impl IntoView {
</div>
</div>
}
}
}

View File

@@ -14,7 +14,6 @@ use typed_builder::TypedBuilder;
/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
/// occur with LeptosOptions
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ConfFile {
pub leptos_options: LeptosOptions,
}
@@ -24,37 +23,31 @@ pub struct ConfFile {
/// correct path for WASM, JS, and Websockets, as well as other configuration tasks.
/// It shares keys with cargo-leptos, to allow for easy interoperability
#[derive(TypedBuilder, Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LeptosOptions {
/// The name of the WASM and JS files generated by wasm-bindgen. Defaults to the crate name with underscores instead of dashes
#[builder(setter(into))]
pub output_name: String,
/// The path of the all the files generated by cargo-leptos. This defaults to '.' for convenience when integrating with other
/// tools.
#[builder(setter(into), default=default_site_root())]
#[serde(default = "default_site_root")]
#[builder(setter(into), default=".".to_string())]
pub site_root: String,
/// The path of the WASM and JS files generated by wasm-bindgen from the root of your app
/// By default, wasm-bindgen puts them in `pkg`.
#[builder(setter(into), default=default_site_pkg_dir())]
#[serde(default = "default_site_pkg_dir")]
#[builder(setter(into), default="pkg".to_string())]
pub site_pkg_dir: String,
/// Used to configure the running environment of Leptos. Can be used to load dev constants and keys v prod, or change
/// things based on the deployment environment
/// I recommend passing in the result of `env::var("LEPTOS_ENV")`
#[builder(setter(into), default=default_env())]
#[serde(default = "default_env")]
#[builder(setter(into), default=Env::DEV)]
pub env: Env,
/// Provides a way to control the address leptos is served from.
/// Using an env variable here would allow you to run the same code in dev and prod
/// Defaults to `127.0.0.1:3000`
#[builder(setter(into), default=default_site_addr())]
#[serde(default = "default_site_addr")]
#[builder(setter(into), default=SocketAddr::from(([127,0,0,1], 3000)))]
pub site_addr: SocketAddr,
/// The port the Websocket watcher listens on. Should match the `reload_port` in cargo-leptos(if using).
/// Defaults to `3001`
#[builder(default = default_reload_port())]
#[serde(default = "default_reload_port")]
#[builder(default = 3001)]
pub reload_port: u32,
}
@@ -88,26 +81,6 @@ impl LeptosOptions {
}
}
fn default_site_root() -> String {
".".to_string()
}
fn default_site_pkg_dir() -> String {
"pkg".to_string()
}
fn default_env() -> Env {
Env::DEV
}
fn default_site_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], 3000))
}
fn default_reload_port() -> u32 {
3001
}
fn env_w_default(
key: &str,
default: &str,
@@ -122,7 +95,7 @@ fn env_w_default(
/// An enum that can be used to define the environment Leptos is running in.
/// Setting this to the `PROD` variant will not include the WebSocket code for `cargo-leptos` watch mode.
/// Defaults to `DEV`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, serde::Deserialize)]
pub enum Env {
PROD,
DEV,
@@ -202,7 +175,9 @@ pub fn get_config_from_str(text: &str) -> Result<ConfFile, LeptosConfigError> {
// so that serde error messages have right line number
let newlines = text[..start].matches('\n').count();
let input = "\n".repeat(newlines) + &text[start..];
let toml = input.replace(metadata_name, "[leptos-options]");
let toml = input
.replace(metadata_name, "[leptos_options]")
.replace('-', "_");
let settings = Config::builder()
// Read the "default" configuration file
.add_source(File::from_str(&toml, FileFormat::Toml))

View File

@@ -46,7 +46,7 @@ async fn get_configuration_from_file_ok() {
.unwrap()
.leptos_options;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
@@ -93,7 +93,7 @@ async fn get_config_from_file_ok() {
.unwrap()
.leptos_options;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
@@ -128,7 +128,7 @@ fn get_config_from_str_content() {
let config = get_config_from_str(CARGO_TOML_CONTENT_OK)
.unwrap()
.leptos_options;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
@@ -141,14 +141,14 @@ fn get_config_from_str_content() {
#[tokio::test]
async fn get_config_from_env() {
// Test config values from environment variables
std::env::set_var("LEPTOS_OUTPUT_NAME", "app-test");
std::env::set_var("LEPTOS_OUTPUT_NAME", "app_test");
std::env::set_var("LEPTOS_SITE_ROOT", "my_target/site");
std::env::set_var("LEPTOS_SITE_PKG_DIR", "my_pkg");
std::env::set_var("LEPTOS_SITE_ADDR", "0.0.0.0:80");
std::env::set_var("LEPTOS_RELOAD_PORT", "8080");
let config = get_configuration(None).await.unwrap().leptos_options;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
@@ -176,8 +176,8 @@ async fn get_config_from_env() {
#[test]
fn leptos_options_builder_default() {
let conf = LeptosOptions::builder().output_name("app-test").build();
assert_eq!(conf.output_name, "app-test");
let conf = LeptosOptions::builder().output_name("app_test").build();
assert_eq!(conf.output_name, "app_test");
assert!(matches!(conf.env, Env::DEV));
assert_eq!(conf.site_pkg_dir, "pkg");
assert_eq!(conf.site_root, ".");

View File

@@ -14,7 +14,7 @@ drain_filter_polyfill = "0.1"
educe = "0.4"
futures = "0.3"
html-escape = "0.2"
indexmap = "2"
indexmap = "1.9"
itertools = "0.10"
js-sys = "0.3"
leptos_reactive = { workspace = true }
@@ -49,7 +49,6 @@ features = [
"Range",
"Text",
"HtmlCollection",
"ShadowRoot",
"TreeWalker",
# Events we cast to in leptos_macro -- added here so we don't force users to import them

View File

@@ -55,8 +55,6 @@ fn view_fn(cx: Scope) -> impl IntoView {
<hr/>
<Test from=[1, 4, 3, 2, 5] to=[1, 2, 3, 4, 5]/>
<Test from=[4, 5, 3, 1, 2] to=[1, 2, 3, 4, 5]/>
<Test from=[0, 1, 2, 3] to=[1, 3]/> // issue #1274
<Test from=[] to=[3, 9, 17] then=vec![3, 5, 7, 9, 17, 23]/> // issue #1297
</ul>
}
}

View File

@@ -13,11 +13,11 @@ mod web {
};
pub use drain_filter_polyfill::VecExt as VecDrainFilterExt;
pub use leptos_reactive::create_effect;
pub use once_cell::unsync::OnceCell;
pub use std::cell::OnceCell;
pub use wasm_bindgen::JsCast;
}
#[allow(dead_code)] // not used in SSR
#[cfg(all(target_arch = "wasm32", feature = "web"))]
type FxIndexSet<T> =
indexmap::IndexSet<T, std::hash::BuildHasherDefault<rustc_hash::FxHasher>>;
@@ -230,12 +230,7 @@ impl EachItem {
fragment.append_with_node_1(&closing.node).unwrap();
}
// if child view is Text and if we are hydrating, we do not
// need to mount it. otherwise, mount it here
if !HydrationCtx::is_hydrating() || !matches!(child, View::Text(_))
{
mount_child(MountKind::Before(&closing.node), &child);
}
mount_child(MountKind::Before(&closing.node), &child);
Some(fragment)
} else {
@@ -499,8 +494,8 @@ where
#[educe(Debug)]
struct HashRun<T>(#[educe(Debug(ignore))] T);
/// Calculates the operations needed to get from `from` to `to`.
#[allow(dead_code)] // not used in SSR but useful to have available for testing
/// Calculates the operations need to get from `a` to `b`.
#[cfg(all(target_arch = "wasm32", feature = "web"))]
fn diff<K: Eq + Hash>(from: &FxIndexSet<K>, to: &FxIndexSet<K>) -> Diff {
if from.is_empty() && to.is_empty() {
return Diff::default();
@@ -523,90 +518,207 @@ fn diff<K: Eq + Hash>(from: &FxIndexSet<K>, to: &FxIndexSet<K>) -> Diff {
};
}
let mut removed = vec![];
let mut moved = vec![];
let mut added = vec![];
let max_len = std::cmp::max(from.len(), to.len());
// Get removed items
let removed = from.difference(to);
for index in 0..max_len {
let from_item = from.get_index(index);
let to_item = to.get_index(index);
let remove_cmds = removed
.clone()
.map(|k| from.get_full(k).unwrap().0)
.map(|idx| DiffOpRemove { at: idx });
// if they're the same, do nothing
if from_item != to_item {
// if it's only in old, not new, remove it
if from_item.is_some() && !to.contains(from_item.unwrap()) {
let op = DiffOpRemove { at: index };
removed.push(op);
}
// if it's only in new, not old, add it
if to_item.is_some() && !from.contains(to_item.unwrap()) {
let op = DiffOpAdd {
at: index,
mode: DiffOpAddMode::Normal,
};
added.push(op);
}
// if it's in both old and new, it can either
// 1) be moved (and need to move in the DOM)
// 2) be moved (but not need to move in the DOM)
// * this would happen if, for example, 2 items
// have been added before it, and it has moved by 2
if let Some(from_item) = from_item {
if let Some(to_item) = to.get_full(from_item) {
let moves_forward_by = (to_item.0 as i32) - (index as i32);
let move_in_dom = moves_forward_by
!= (added.len() as i32) - (removed.len() as i32);
// Get added items
let added = to.difference(from);
let op = DiffOpMove {
from: index,
len: 1,
to: to_item.0,
move_in_dom,
};
moved.push(op);
}
}
}
}
let add_cmds =
added
.clone()
.map(|k| to.get_full(k).unwrap().0)
.map(|idx| DiffOpAdd {
at: idx,
mode: Default::default(),
});
moved = group_adjacent_moves(moved);
// Get items that might have moved
let from_moved = from.intersection(&to).collect::<FxIndexSet<_>>();
let to_moved = to.intersection(&from).collect::<FxIndexSet<_>>();
Diff {
removed,
items_to_move: moved.iter().map(|m| m.len).sum(),
moved,
added,
let move_cmds = find_ranges(from_moved, to_moved, from, to);
let mut diff = Diff {
removed: remove_cmds.collect(),
items_to_move: move_cmds.iter().map(|range| range.len).sum(),
moved: move_cmds,
added: add_cmds.collect(),
clear: false,
};
apply_opts(from, to, &mut diff);
#[cfg(test)]
{
let mut adds_sorted = diff.added.clone();
adds_sorted.sort_unstable_by_key(|add| add.at);
assert_eq!(diff.added, adds_sorted, "adds must be sorted");
let mut moves_sorted = diff.moved.clone();
moves_sorted.sort_unstable_by_key(|move_| move_.to);
assert_eq!(diff.moved, moves_sorted, "moves must be sorted by `to`");
}
diff
}
/// Group adjacent items that are being moved as a group.
/// For example from `[2, 3, 5, 6]` to `[1, 2, 3, 4, 5, 6]` should result
/// in a move for `2,3` and `5,6` rather than 4 individual moves.
fn group_adjacent_moves(moved: Vec<DiffOpMove>) -> Vec<DiffOpMove> {
let mut prev: Option<DiffOpMove> = None;
let mut new_moved = Vec::with_capacity(moved.len());
for m in moved {
match prev {
Some(mut p) => {
if (m.from == p.from + p.len) && (m.to == p.to + p.len) {
p.len += 1;
prev = Some(p);
} else {
new_moved.push(prev.take().unwrap());
prev = Some(m);
}
}
None => prev = Some(m),
/// Builds and returns the ranges of items that need to
/// move sorted by `to`.
#[cfg(all(target_arch = "wasm32", feature = "web"))]
fn find_ranges<K: Eq + Hash>(
from_moved: FxIndexSet<&K>,
to_moved: FxIndexSet<&K>,
from: &FxIndexSet<K>,
to: &FxIndexSet<K>,
) -> Vec<DiffOpMove> {
let mut ranges = Vec::with_capacity(from.len());
let mut prev_to_moved_index = 0;
let mut range = DiffOpMove::default();
for (i, k) in from_moved.into_iter().enumerate() {
let to_moved_index = to_moved.get_index_of(k).unwrap();
if i == 0 {
range.from = from.get_index_of(k).unwrap();
range.to = to.get_index_of(k).unwrap();
}
// The range continues
else if to_moved_index == prev_to_moved_index + 1 {
range.len += 1;
}
// We're done with this range, start a new one
else {
ranges.push(std::mem::take(&mut range));
range.from = from.get_index_of(k).unwrap();
range.to = to.get_index_of(k).unwrap();
}
prev_to_moved_index = to_moved_index;
}
ranges.push(std::mem::take(&mut range));
// We need to remove ranges that didn't move relative to each other
// as well as marking items that don't need to move in the DOM
let mut to_ranges = ranges.clone();
to_ranges.sort_unstable_by_key(|range| range.to);
let mut filtered_ranges = vec![];
let to_ranges_len = to_ranges.len();
for (i, range) in to_ranges.into_iter().enumerate() {
if range != ranges[i] {
filtered_ranges.push(range);
}
// The item did move, just not in the DOM
else if range.from != range.to {
filtered_ranges.push(DiffOpMove {
move_in_dom: false,
..range
});
} else if to_ranges_len > 2 {
// TODO: Remove this else case...this is one of the biggest
// optimizations we can do, but we're skipping this right now
// until we figure out a way to handle moving around ranges
// that did not move
filtered_ranges.push(range);
}
}
if let Some(prev) = prev {
new_moved.push(prev)
}
new_moved
filtered_ranges
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
fn apply_opts<K: Eq + Hash>(
from: &FxIndexSet<K>,
to: &FxIndexSet<K>,
cmds: &mut Diff,
) {
optimize_moves(&mut cmds.moved);
// We can optimize the case of replacing all items
if !from.is_empty()
&& !to.is_empty()
&& cmds.removed.len() == from.len()
&& cmds.moved.is_empty()
{
cmds.clear = true;
cmds.removed.clear();
cmds.added
.iter_mut()
.for_each(|op| op.mode = DiffOpAddMode::Append);
return;
}
// We can optimize appends.
if !cmds.added.is_empty()
&& cmds.moved.is_empty()
&& cmds.removed.is_empty()
&& cmds.added[0].at >= from.len()
{
cmds.added
.iter_mut()
.for_each(|op| op.mode = DiffOpAddMode::Append);
}
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
fn optimize_moves(moves: &mut Vec<DiffOpMove>) {
if moves.is_empty() || moves.len() == 1 {
// Do nothing
}
// This is the easiest optimal move case, which is to
// simply swap the 2 ranges. We only need to move the range
// that is smallest.
else if moves.len() == 2 {
if moves[1].len < moves[0].len {
moves[0].move_in_dom = false;
} else {
moves[1].move_in_dom = false;
}
}
// Interestingly enoughs, there are NO configuration that are possible
// for ranges of 3.
//
// For example, take A, B, C. Here are all possible configurations and
// reasons for why they are impossible:
// - A B C # identity, would be removed by ranges that didn't move
// - A C B # `A` would be removed, thus it's a case of length 2
// - B A C # `C` would be removed, thus it's a case of length 2
// - B C A # `B C` are congiguous, so this is would have been a single range
// - C A B # `A B` are congiguous, so this is would have been a single range
// - C B A # `B` would be removed, thus it's a case of length 2
//
// We can add more pre-computed tables here if benchmarking or
// user demand needs it...nevertheless, it is unlikely for us
// to implement this algorithm to handle N ranges, because this
// becomes exponentially more expensive to compute. It's faster,
// for the most part, to assume the ranges are random and move
// all the ranges around than to try and figure out the best way
// to move them
else {
// The idea here is that for N ranges, we never need to
// move the largest range, rather, have all ranges move
// around it.
let move_ = moves.iter_mut().max_by_key(|move_| move_.len).unwrap();
move_.move_in_dom = false;
}
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
#[derive(Debug, Default, PartialEq, Eq)]
struct Diff {
removed: Vec<DiffOpRemove>,
@@ -616,6 +728,7 @@ struct Diff {
clear: bool,
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DiffOpMove {
/// The index this range is starting relative to `from`.
@@ -629,6 +742,7 @@ struct DiffOpMove {
move_in_dom: bool,
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
impl Default for DiffOpMove {
fn default() -> Self {
Self {
@@ -640,18 +754,20 @@ impl Default for DiffOpMove {
}
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct DiffOpAdd {
at: usize,
mode: DiffOpAddMode,
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
#[derive(Debug, PartialEq, Eq)]
struct DiffOpRemove {
at: usize,
}
#[allow(dead_code)] // Append not used in SSR but useful
#[cfg(all(target_arch = "wasm32", feature = "web"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DiffOpAddMode {
Normal,
@@ -660,6 +776,7 @@ enum DiffOpAddMode {
_Prepend,
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
impl Default for DiffOpAddMode {
fn default() -> Self {
Self::Normal
@@ -869,173 +986,404 @@ fn unpack_moves(diff: &Diff) -> (Vec<DiffOpMove>, Vec<DiffOpAdd>) {
(moves, adds)
}
#[cfg(test)]
mod test_utils {
use super::*;
// #[cfg(test)]
// mod test_utils {
// use super::*;
pub trait IntoFxIndexSet<K> {
fn into_fx_index_set(self) -> FxIndexSet<K>;
}
// pub trait IntoFxIndexSet<K> {
// fn into_fx_index_set(self) -> FxIndexSet<K>;
// }
impl<T, K> IntoFxIndexSet<K> for T
where
T: IntoIterator<Item = K>,
K: Eq + Hash,
{
fn into_fx_index_set(self) -> FxIndexSet<K> {
self.into_iter().collect()
}
}
}
// impl<T, K> IntoFxIndexSet<K> for T
// where
// T: IntoIterator<Item = K>,
// K: Eq + Hash,
// {
// fn into_fx_index_set(self) -> FxIndexSet<K> {
// self.into_iter().collect()
// }
// }
// }
#[cfg(test)]
use test_utils::*;
// #[cfg(test)]
// use test_utils::*;
#[cfg(test)]
mod diff {
use super::*;
// #[cfg(test)]
// mod find_ranges {
// use super::*;
#[test]
fn only_adds() {
let diff =
diff(&[].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
// // Single range tests will be empty because of removing ranges
// // that didn't move
// #[test]
// fn single_range() {
// let ranges = find_ranges(
// [1, 2, 3, 4].iter().into_fx_index_set(),
// [1, 2, 3, 4].iter().into_fx_index_set(),
// &[1, 2, 3, 4].into_fx_index_set(),
// &[1, 2, 3, 4].into_fx_index_set(),
// );
assert_eq!(
diff,
Diff {
added: vec![
DiffOpAdd {
at: 0,
mode: DiffOpAddMode::Append
},
DiffOpAdd {
at: 1,
mode: DiffOpAddMode::Append
},
DiffOpAdd {
at: 2,
mode: DiffOpAddMode::Append
},
],
..Default::default()
}
);
}
// assert_eq!(ranges, vec![]);
// }
#[test]
fn only_removes() {
let diff =
diff(&[1, 2, 3].into_fx_index_set(), &[3].into_fx_index_set());
// #[test]
// fn single_range_with_adds() {
// let ranges = find_ranges(
// [1, 2, 3, 4].iter().into_fx_index_set(),
// [1, 2, 3, 4].iter().into_fx_index_set(),
// &[1, 2, 3, 4].into_fx_index_set(),
// &[1, 2, 5, 3, 4].into_fx_index_set(),
// );
assert_eq!(
diff,
Diff {
removed: vec![DiffOpRemove { at: 0 }, DiffOpRemove { at: 1 }],
moved: vec![DiffOpMove {
from: 2,
len: 1,
to: 0,
move_in_dom: false
}],
items_to_move: 1,
..Default::default()
}
);
}
// assert_eq!(ranges, vec![]);
// }
#[test]
fn adds_with_no_move() {
let diff =
diff(&[3].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
// #[test]
// fn single_range_with_removals() {
// let ranges = find_ranges(
// [1, 2, 3, 4].iter().into_fx_index_set(),
// [1, 2, 3, 4].iter().into_fx_index_set(),
// &[1, 2, 5, 3, 4].into_fx_index_set(),
// &[1, 2, 3, 4].into_fx_index_set(),
// );
assert_eq!(
diff,
Diff {
added: vec![
DiffOpAdd {
at: 0,
..Default::default()
},
DiffOpAdd {
at: 1,
..Default::default()
},
],
moved: vec![DiffOpMove {
from: 0,
len: 1,
to: 2,
move_in_dom: true
}],
items_to_move: 1,
..Default::default()
}
);
}
// assert_eq!(ranges, vec![]);
// }
#[test]
fn move_as_group() {
let diff = diff(
&[2, 3, 4, 5].into_fx_index_set(),
&[1, 2, 3, 4, 5].into_fx_index_set(),
);
// #[test]
// fn two_ranges() {
// let ranges = find_ranges(
// [1, 2, 3, 4].iter().into_fx_index_set(),
// [3, 4, 1, 2].iter().into_fx_index_set(),
// &[1, 2, 3, 4].into_fx_index_set(),
// &[3, 4, 1, 2].into_fx_index_set(),
// );
assert_eq!(
diff,
Diff {
added: vec![DiffOpAdd {
at: 0,
..Default::default()
},],
moved: vec![DiffOpMove {
from: 0,
len: 4,
to: 1,
move_in_dom: false
},],
items_to_move: 4,
..Default::default()
}
);
}
// assert_eq!(
// ranges,
// vec![
// DiffOpMove {
// from: 2,
// to: 0,
// len: 2,
// move_in_dom: true,
// },
// DiffOpMove {
// from: 0,
// to: 2,
// len: 2,
// move_in_dom: true,
// },
// ]
// );
// }
#[test]
fn move_as_group_with_gap() {
let diff = diff(
&[2, 3, 5, 6].into_fx_index_set(),
&[1, 2, 3, 4, 5, 6].into_fx_index_set(),
);
// #[test]
// fn two_ranges_with_adds() {
// let ranges = find_ranges(
// [1, 2, 3, 4].iter().into_fx_index_set(),
// [3, 4, 1, 2].iter().into_fx_index_set(),
// &[1, 2, 3, 4].into_fx_index_set(),
// &[3, 4, 5, 1, 6, 2].into_fx_index_set(),
// );
assert_eq!(
diff,
Diff {
added: vec![
DiffOpAdd {
at: 0,
..Default::default()
},
DiffOpAdd {
at: 3,
..Default::default()
},
],
moved: vec![
DiffOpMove {
from: 0,
len: 2,
to: 1,
move_in_dom: false
},
DiffOpMove {
from: 2,
len: 2,
to: 4,
move_in_dom: true
}
],
items_to_move: 4,
..Default::default()
}
);
}
}
// assert_eq!(
// ranges,
// vec![
// DiffOpMove {
// from: 2,
// to: 0,
// len: 2,
// },
// DiffOpMove {
// from: 0,
// to: 3,
// len: 2,
// },
// ]
// );
// }
// #[test]
// fn two_ranges_with_removals() {
// let ranges = find_ranges(
// [1, 2, 3, 4].iter().into_fx_index_set(),
// [3, 4, 1, 2].iter().into_fx_index_set(),
// &[1, 5, 2, 6, 3, 4].into_fx_index_set(),
// &[3, 4, 1, 2].into_fx_index_set(),
// );
// assert_eq!(
// ranges,
// vec![
// DiffOpMove {
// from: 4,
// to: 0,
// len: 2,
// },
// DiffOpMove {
// from: 0,
// to: 2,
// len: 2,
// },
// ]
// );
// }
// #[test]
// fn remove_ranges_that_did_not_move() {
// // Here, 'C' doesn't change
// let ranges = find_ranges(
// ['A', 'B', 'C', 'D'].iter().into_fx_index_set(),
// ['B', 'D', 'C', 'A'].iter().into_fx_index_set(),
// &['A', 'B', 'C', 'D'].into_fx_index_set(),
// &['B', 'D', 'C', 'A'].into_fx_index_set(),
// );
// assert_eq!(
// ranges,
// vec![
// DiffOpMove {
// from: 1,
// to: 0,
// len: 1,
// },
// DiffOpMove {
// from: 3,
// to: 1,
// len: 1,
// },
// DiffOpMove {
// from: 0,
// to: 3,
// len: 1,
// },
// ]
// );
// // Now we're going to to the same as above, just with more items
// //
// // A = 1
// // B = 2, 3
// // C = 4, 5, 6
// // D = 7, 8, 9, 0
// let ranges = find_ranges(
// //A B C D
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 0].iter().into_fx_index_set(),
// //B D C A
// [2, 3, 7, 8, 9, 0, 4, 5, 6, 1].iter().into_fx_index_set(),
// //A B C D
// &[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].into_fx_index_set(),
// //B D C A
// &[2, 3, 7, 8, 9, 0, 4, 5, 6, 1].into_fx_index_set(),
// );
// assert_eq!(
// ranges,
// vec![
// DiffOpMove {
// from: 1,
// to: 0,
// len: 2,
// },
// DiffOpMove {
// from: 6,
// to: 2,
// len: 4,
// },
// DiffOpMove {
// from: 0,
// to: 9,
// len: 1,
// },
// ]
// );
// }
// }
// #[cfg(test)]
// mod optimize_moves {
// use super::*;
// #[test]
// fn swap() {
// let mut moves = vec![
// DiffOpMove {
// from: 0,
// to: 6,
// len: 2,
// ..Default::default()
// },
// DiffOpMove {
// from: 6,
// to: 0,
// len: 7,
// ..Default::default()
// },
// ];
// optimize_moves(&mut moves);
// assert_eq!(
// moves,
// vec![DiffOpMove {
// from: 0,
// to: 6,
// len: 2,
// ..Default::default()
// }]
// );
// }
// }
// #[cfg(test)]
// mod add_or_move {
// use super::*;
// #[test]
// fn simple_range() {
// let cmds = AddOrMove::from_diff(&Diff {
// moved: vec![DiffOpMove {
// from: 0,
// to: 0,
// len: 3,
// }],
// ..Default::default()
// });
// assert_eq!(
// cmds,
// vec![
// DiffOpMove {
// from: 0,
// to: 0,
// len: 1,
// },
// DiffOpMove {
// from: 1,
// to: 1,
// len: 1,
// },
// DiffOpMove {
// from: 2,
// to: 2,
// len: 1,
// },
// ]
// );
// }
// #[test]
// fn range_with_add() {
// let cmds = AddOrMove::from_diff(&Diff {
// moved: vec![DiffOpMove {
// from: 0,
// to: 0,
// len: 3,
// move_in_dom: true,
// }],
// added: vec![DiffOpAdd {
// at: 2,
// ..Default::default()
// }],
// ..Default::default()
// });
// assert_eq!(
// cmds,
// vec![
// AddOrMove::Move(DiffOpMove {
// from: 0,
// to: 0,
// len: 1,
// move_in_dom: true,
// }),
// AddOrMove::Move(DiffOpMove {
// from: 1,
// to: 1,
// len: 1,
// move_in_dom: true,
// }),
// AddOrMove::Add(DiffOpAdd {
// at: 2,
// ..Default::default()
// }),
// AddOrMove::Move(DiffOpMove {
// from: 3,
// to: 3,
// len: 1,
// move_in_dom: true,
// }),
// ]
// );
// }
// }
// #[cfg(test)]
// mod diff {
// use super::*;
// #[test]
// fn only_adds() {
// let diff =
// diff(&[].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
// assert_eq!(
// diff,
// Diff {
// added: vec![
// DiffOpAdd {
// at: 0,
// mode: DiffOpAddMode::Append
// },
// DiffOpAdd {
// at: 1,
// mode: DiffOpAddMode::Append
// },
// DiffOpAdd {
// at: 2,
// mode: DiffOpAddMode::Append
// },
// ],
// ..Default::default()
// }
// );
// }
// #[test]
// fn only_removes() {
// let diff =
// diff(&[1, 2, 3].into_fx_index_set(), &[3].into_fx_index_set());
// assert_eq!(
// diff,
// Diff {
// removed: vec![DiffOpRemove { at: 0 }, DiffOpRemove { at: 1 }],
// ..Default::default()
// }
// );
// }
// #[test]
// fn adds_with_no_move() {
// let diff =
// diff(&[3].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
// assert_eq!(
// diff,
// Diff {
// added: vec![
// DiffOpAdd {
// at: 0,
// ..Default::default()
// },
// DiffOpAdd {
// at: 1,
// ..Default::default()
// },
// ],
// ..Default::default()
// }
// );
// }
// }

View File

@@ -158,13 +158,19 @@ pub(crate) fn add_delegated_event_listener(
}
// navigate up tree
if let Some(parent) =
node.unchecked_ref::<web_sys::Node>().parent_node()
let host =
js_sys::Reflect::get(&node, &JsValue::from_str("host"))
.unwrap_throw();
if host.is_truthy()
&& host != node
&& host.dyn_ref::<web_sys::Node>().is_some()
{
node = host;
} else if let Some(parent) =
node.unchecked_into::<web_sys::Node>().parent_node()
{
node = parent.into()
} else if let Some(root) = node.dyn_ref::<web_sys::ShadowRoot>() {
node = root.host().unchecked_into();
} else {
} else {
node = JsValue::null()
}
}

View File

@@ -483,9 +483,6 @@ impl Text {
/// A leptos view which can be mounted to the DOM.
#[derive(Clone, PartialEq, Eq)]
#[must_use = "You are creating a View but not using it. An unused view can \
cause your view to be rendered as () unexpectedly, and it can \
also cause issues with client-side hydration."]
pub enum View {
/// HTML element node.
Element(Element),
@@ -732,7 +729,7 @@ impl View {
c.children.iter().cloned().for_each(|c| {
let event_handler = event_handler.clone();
_ = c.on(event.clone(), Box::new(move |e| event_handler.borrow_mut()(e)));
c.on(event.clone(), Box::new(move |e| event_handler.borrow_mut()(e)));
});
}
Self::CoreComponent(c) => match c {
@@ -841,7 +838,9 @@ where
crate::console_warn(
"You have both `csr` and `ssr` or `hydrate` and `ssr` enabled as \
features, which may cause issues like <Suspense/>` failing to work \
silently.",
silently. `csr` is enabled by default on `leptos`, and can be \
disabled by adding `default-features = false` to your `leptos` \
dependency.",
);
cfg_if! {

View File

@@ -79,7 +79,6 @@ pub fn class_helper(
name: Cow<'static, str>,
value: Class,
) {
use crate::HydrationCtx;
use leptos_reactive::create_render_effect;
let class_list = el.class_list();
@@ -87,9 +86,7 @@ pub fn class_helper(
Class::Fn(cx, f) => {
create_render_effect(cx, move |old| {
let new = f();
if old.as_ref() != Some(&new)
&& (old.is_some() || new || HydrationCtx::is_hydrating())
{
if old.as_ref() != Some(&new) && (old.is_some() || new) {
class_expression(&class_list, &name, new, true)
}
new

View File

@@ -368,7 +368,9 @@ impl View {
crate::console_error(
"\n[DANGER] You have both `csr` and `ssr` or `hydrate` and `ssr` \
enabled as features, which may cause issues like <Suspense/>` \
failing to work silently.\n",
failing to work silently. `csr` is enabled by default on \
`leptos`, and can be disabled by adding `default-features = \
false` to your `leptos` dependency.\n",
);
self.render_to_string_helper(false)

View File

@@ -59,7 +59,9 @@ pub fn render_to_stream_in_order_with_prefix(
crate::console_error(
"\n[DANGER] You have both `csr` and `ssr` or `hydrate` and `ssr` \
enabled as features, which may cause issues like <Suspense/>` \
failing to work silently.\n",
failing to work silently. `csr` is enabled by default on `leptos`, \
and can be disabled by adding `default-features = false` to your \
`leptos` dependency.\n",
);
let (stream, runtime, _) =

View File

@@ -24,4 +24,4 @@ proc-macro2 = { version = "1", features = ["span-locations", "nightly"] }
parking_lot = "0.12"
walkdir = "2"
camino = "1.1.3"
indexmap = "2"
indexmap = "1.9.2"

View File

@@ -41,7 +41,7 @@ web-sys = { version = "0.3", optional = true, features = [
"Window",
] }
cfg-if = "1"
indexmap = "2"
indexmap = "1"
self_cell = "1.0.0"
[dev-dependencies]

View File

@@ -863,21 +863,6 @@ where
}
}
// on cleanup of this component, remove this read from parent `<Suspense/>`
// it will be added back in when this is rendered again
if let Some(s) = suspense_cx {
crate::on_cleanup(cx, {
let suspense_contexts = Rc::clone(&suspense_contexts);
move || {
if let Ok(ref mut contexts) =
suspense_contexts.try_borrow_mut()
{
contexts.remove(&s);
}
}
});
}
let increment = move |_: Option<()>| {
if let Some(s) = &suspense_cx {
if let Ok(ref mut contexts) = suspense_contexts.try_borrow_mut()

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.4.2"
version = "0.4.0"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -12,7 +12,7 @@ cfg-if = "1"
leptos = { workspace = true }
tracing = "0.1"
wasm-bindgen = "0.2"
indexmap = "2"
indexmap = "1"
[dependencies.web-sys]
version = "0.3"

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_router"
version = "0.4.2"
version = "0.4.0"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -17,7 +17,6 @@ gloo-net = { version = "0.2", features = ["http"] }
lazy_static = "1"
linear-map = { version = "1", features = ["serde_impl"] }
log = "0.4"
once_cell = "1.18"
regex = { version = "1", optional = true }
url = { version = "2", optional = true }
percent-encoding = "2"

View File

@@ -143,7 +143,10 @@ where
match Url::try_from(resp_url.as_str()) {
Ok(url) => {
if url.origin
!= current_window_origin()
!= window()
.location()
.origin()
.unwrap_or_default()
{
_ = window()
.location()
@@ -224,7 +227,10 @@ where
match Url::try_from(resp_url.as_str()) {
Ok(url) => {
if url.origin
!= current_window_origin()
!= window()
.location()
.hostname()
.unwrap_or_default()
{
_ = window()
.location()
@@ -322,20 +328,6 @@ where
)
}
fn current_window_origin() -> String {
let location = window().location();
let protocol = location.protocol().unwrap_or_default();
let hostname = location.hostname().unwrap_or_default();
let port = location.port().unwrap_or_default();
format!(
"{}//{}{}{}",
protocol,
hostname,
if port.is_empty() { "" } else { ":" },
port
)
}
/// Automatically turns a server [Action](leptos_server::Action) into an HTML
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.
@@ -425,67 +417,70 @@ where
let on_response = Rc::new(move |resp: &web_sys::Response| {
let resp = resp.clone().expect("couldn't get Response");
spawn_local(async move {
let body = JsFuture::from(
resp.text().expect("couldn't get .text() from Response"),
)
.await;
let status = resp.status();
match body {
Ok(json) => {
let json = json
.as_string()
.expect("couldn't get String from JsString");
if (500..=599).contains(&status) {
match serde_json::from_str::<ServerFnError>(&json) {
Ok(res) => {
value.try_set(Some(Err(res)));
if let Some(error) = error {
error.try_set(None);
let redirected = resp.redirected();
if !redirected {
let body = JsFuture::from(
resp.text().expect("couldn't get .text() from Response"),
)
.await;
let status = resp.status();
match body {
Ok(json) => {
let json = json
.as_string()
.expect("couldn't get String from JsString");
if (500..=599).contains(&status) {
match serde_json::from_str::<ServerFnError>(&json) {
Ok(res) => {
value.try_set(Some(Err(res)));
if let Some(error) = error {
error.try_set(None);
}
}
Err(e) => {
value.try_set(Some(Err(
ServerFnError::Deserialization(
e.to_string(),
),
)));
if let Some(error) = error {
error.try_set(Some(Box::new(e)));
}
}
}
Err(e) => {
value.try_set(Some(Err(
ServerFnError::Deserialization(
e.to_string(),
),
)));
if let Some(error) = error {
error.try_set(Some(Box::new(e)));
} else {
match serde_json::from_str::<O>(&json) {
Ok(res) => {
value.try_set(Some(Ok(res)));
if let Some(error) = error {
error.try_set(None);
}
}
}
}
} else {
match serde_json::from_str::<O>(&json) {
Ok(res) => {
value.try_set(Some(Ok(res)));
if let Some(error) = error {
error.try_set(None);
}
}
Err(e) => {
value.try_set(Some(Err(
ServerFnError::Deserialization(
e.to_string(),
),
)));
if let Some(error) = error {
error.try_set(Some(Box::new(e)));
Err(e) => {
value.try_set(Some(Err(
ServerFnError::Deserialization(
e.to_string(),
),
)));
if let Some(error) = error {
error.try_set(Some(Box::new(e)));
}
}
}
}
}
}
Err(e) => {
error!("{e:?}");
if let Some(error) = error {
error.try_set(Some(Box::new(
ServerFnErrorErr::Request(
e.as_string().unwrap_or_default(),
),
)));
Err(e) => {
error!("{e:?}");
if let Some(error) = error {
error.try_set(Some(Box::new(
ServerFnErrorErr::Request(
e.as_string().unwrap_or_default(),
),
)));
}
}
}
};
};
}
cx.batch(move || {
input.try_set(None);
action.set_pending(false);

View File

@@ -1,6 +1,5 @@
use crate::{use_location, use_resolved_path, State};
use leptos::{leptos_dom::IntoView, *};
use std::borrow::Cow;
/// Describes a value that is either a static or a reactive URL, i.e.,
/// a [String], a [&str], or a reactive `Fn() -> String`.
@@ -57,16 +56,6 @@ pub fn A<H>(
/// if false, link is marked active if the current route starts with it.
#[prop(optional)]
exact: bool,
/// Provides a class to be added when the link is active. If provided, it will
/// be added at the same time that the `aria-current` attribute is set.
///
/// This supports multiple space-separated class names.
///
/// **Performance**: If its possible to style the link using the CSS with the
/// `[aria-current=page]` selector, you should prefer that, as it enables significant
/// SSR optimizations.
#[prop(optional, into)]
active_class: Option<Cow<'static, str>>,
/// An object of any type that will be pushed to router state
#[prop(optional)]
state: Option<State>,
@@ -94,13 +83,12 @@ where
cx: Scope,
href: Memo<Option<String>>,
exact: bool,
#[allow(unused)] state: Option<State>,
#[allow(unused)] replace: bool,
state: Option<State>,
replace: bool,
class: Option<AttributeValue>,
#[allow(unused)] active_class: Option<Cow<'static, str>>,
id: Option<String>,
children: Children,
) -> View {
) -> HtmlElement<leptos::html::A> {
#[cfg(not(any(feature = "hydrate", feature = "csr")))]
{
_ = state;
@@ -130,86 +118,20 @@ where
}
});
#[cfg(feature = "ssr")]
{
// if we have `active_class`, the SSR optimization doesn't play nicely
// so we use the builder instead
if let Some(active_class) = active_class {
let mut a = leptos::html::a(cx)
.attr("href", move || href.get().unwrap_or_default())
.attr("aria-current", move || {
if is_active.get() {
Some("page")
} else {
None
}
})
.attr(
"class",
class.map(|class| class.into_attribute_boxed(cx)),
);
for class_name in active_class.split_ascii_whitespace() {
a = a.class(class_name.to_string(), move || is_active.get())
}
a.attr("id", id).child(children(cx)).into_view(cx)
}
// but keep the nice SSR optimization in most cases
else {
view! { cx,
<a
href=move || href.get().unwrap_or_default()
aria-current=move || if is_active.get() { Some("page") } else { None }
class=class
id=id
>
{children(cx)}
</a>
}
.into_view(cx)
}
}
// the non-SSR version doesn't need the SSR optimizations
// DRY here to avoid WASM binary size bloat
#[cfg(not(feature = "ssr"))]
{
let a = view! { cx,
<a
href=move || href.get().unwrap_or_default()
prop:state={state.map(|s| s.to_js_value())}
prop:replace={replace}
aria-current=move || if is_active.get() { Some("page") } else { None }
class=class
id=id
>
{children(cx)}
</a>
};
if let Some(active_class) = active_class {
let mut a = a;
for class_name in active_class.split_ascii_whitespace() {
a = a.class(class_name.to_string(), move || is_active.get())
}
a
} else {
a
}
.into_view(cx)
view! { cx,
<a
href=move || href.get().unwrap_or_default()
prop:state={state.map(|s| s.to_js_value())}
prop:replace={replace}
aria-current=move || if is_active.get() { Some("page") } else { None }
class=class
id=id
>
{children(cx)}
</a>
}
}
let href = use_resolved_path(cx, move || href.to_href()());
inner(
cx,
href,
exact,
state,
replace,
class,
active_class,
id,
children,
)
inner(cx, href, exact, state, replace, class, id, children)
}

View File

@@ -33,7 +33,7 @@ where
{
// resolve relative path
let path = use_resolved_path(cx, move || path.to_string());
let path = path.get_untracked().unwrap_or_else(|| "/".to_string());
let path = path.get().unwrap_or_else(|| "/".to_string());
// redirect on the server
if let Some(redirect_fn) = use_context::<ServerRedirectFunction>(cx) {

View File

@@ -126,16 +126,12 @@ impl History for BrowserIntegration {
.unwrap_or(hash);
let el = leptos_dom::document().get_element_by_id(&hash);
if let Some(el) = el {
el.scroll_into_view();
return;
el.scroll_into_view()
} else if loc.scroll {
leptos_dom::window().scroll_to_with_x_and_y(0.0, 0.0);
}
}
}
// scroll to top
if loc.scroll {
leptos_dom::window().scroll_to_with_x_and_y(0.0, 0.0);
}
}
}

View File

@@ -3,20 +3,14 @@ use std::borrow::Cow;
#[doc(hidden)]
#[cfg(not(feature = "ssr"))]
pub fn expand_optionals(pattern: &str) -> Vec<Cow<str>> {
use js_sys::RegExp;
use once_cell::unsync::Lazy;
use wasm_bindgen::JsValue;
thread_local! {
static OPTIONAL_RE: Lazy<RegExp> = Lazy::new(|| {
RegExp::new(OPTIONAL, "")
});
static OPTIONAL_RE_2: Lazy<RegExp> = Lazy::new(|| {
RegExp::new(OPTIONAL_2, "")
});
}
#[allow(non_snake_case)]
let OPTIONAL_RE = js_sys::RegExp::new(OPTIONAL, "");
#[allow(non_snake_case)]
let OPTIONAL_RE_2 = js_sys::RegExp::new(OPTIONAL_2, "");
let captures = OPTIONAL_RE.with(|re| re.exec(pattern));
let captures = OPTIONAL_RE.exec(pattern);
match captures {
None => vec![pattern.into()],
Some(matched) => {
@@ -34,7 +28,7 @@ pub fn expand_optionals(pattern: &str) -> Vec<Cow<str>> {
prefixes.push(prefix.clone());
while let Some(matched) =
OPTIONAL_RE_2.with(|re| re.exec(suffix.trim_start_matches('?')))
OPTIONAL_RE_2.exec(suffix.trim_start_matches('?'))
{
prefix += &matched.get(1).as_string().unwrap();
prefixes.push(prefix.clone());