Compare commits

..

70 Commits
v0.4.0 ... keys

Author SHA1 Message Date
Greg Johnston
2aa14e5e65 differentiate suspense/errorboundary 2023-07-15 16:35:08 -04:00
Greg Johnston
40f6ed9252 make component key paths independent 2023-07-15 12:59:12 -04:00
Greg Johnston
9d9ed45565 make hydration keys !Copy 2023-07-15 10:47:27 -04:00
Greg Johnston
8e68699435 feat: add support for adding CSP nonces (#1348) 2023-07-14 16:37:18 -04:00
Greg Johnston
77580401da fix: hydration-key conflicts between <ErrorBoundary/> children and fallback (#1354) 2023-07-14 14:37:54 -04:00
Joseph Cruz
7902e7edb7 ci: speed up verification (#1347)
* build: introduce ci task
* refactor(ci): rename cargo make task runner
* ci: add ci workflow
* ci: remove redundant workflows
2023-07-14 14:37:17 -04:00
Greg Johnston
4ad223277d fix: duplicated meta content during async rendering (#1352) 2023-07-14 13:14:19 -04:00
Greg Johnston
6f5da11c72 docs/warnings: don't warn when a resource resolves after its scope has been disposed (#1351) 2023-07-14 13:09:57 -04:00
Greg Johnston
3eed86fbf3 docs/warnings: improve ServerFnError when a server function is not found (#1350) 2023-07-14 12:43:08 -04:00
Greg Johnston
10d51a854a v0.4.3 2023-07-14 09:22:19 -04:00
CircuitSacul
6c60bad757 examples: use cfg_attr for conditional derives (#1349)
Revert "use cfg_attr for conditional derives"

This reverts commit b5c85e9ed8a84e5a49f119ae8436d78f2bee1fea.

Revert "Revert "use cfg_attr for conditional derives""

This reverts commit b538256cd69bc2321cdc9066441d71fc94ed80b9.
2023-07-14 09:20:55 -04:00
g-re-g
79f666b5da docs: don't run rust code snippets and update getting started (#1346)
* disable running leptos examples

* typo and small language changes to 02_getting_started
2023-07-14 07:10:59 -04:00
Greg Johnston
3ea3a40395 fix: server_fn rustls feature shouldn't pull in default-tls (#1343) 2023-07-13 19:41:55 -04:00
Greg Johnston
193aa79956 docs: how not to mutate the DOM during rendering (#1344) 2023-07-13 16:57:07 -04:00
Joseph Cruz
3481a6ee53 build: run tasks from workpace or member directory (#1339) 2023-07-13 16:46:51 -04:00
Greg Johnston
d1ef5fce9f fix: un-register <Suspense/> from resource reads when <Suspense/> is unmounted (#1342) 2023-07-13 14:42:05 -04:00
Greg Johnston
5d48911f01 fix: check LEPTOS_OUTPUT_NAME correctly at compile time (#1338) 2023-07-13 10:49:13 -04:00
Greg Johnston
8a90f97959 fix: routing logic to scroll to top was broken (#1335) 2023-07-13 06:43:49 -04:00
Greg Johnston
e9665b34e5 feat: add active_class prop on <A/> (#1323) 2023-07-12 16:21:07 -04:00
Greg Johnston
d4b1ceda90 fix: event delegation issue with <input name="host"> (#1332) 2023-07-12 16:20:11 -04:00
Greg Johnston
a0fae88f7d docs: clarify nightly in "Getting Started" (#1330) 2023-07-12 11:56:45 -04:00
Greg Johnston
03a8609680 fix: warning generated by new #[must_use] on views (#1329) 2023-07-12 10:00:05 -04:00
g-re-g
3e40f9cc66 chore: bump indexmap to 2 (#1325) 2023-07-11 15:24:21 -04:00
Greg Johnston
576bb078f7 fix: Actix server fn redirect() duplicate Location headers (#1326) 2023-07-11 13:57:44 -04:00
Filip Dutescu
3cdcc85c87 feat(config): implement common traits for Env (#1324)
In order to facilitate its usage in other structs, which might derive
various traits, such as `serde::Serialize` or `PartialEq`, implement
them for the `leptos_config::Env` enum.

Signed-off-by: Filip Dutescu <filip@hucksy.dev>
2023-07-11 09:37:42 -04:00
Greg Johnston
ec3a26dfbc fix: <ActionForm/> should set value even if redirected (#1321) 2023-07-11 09:37:13 -04:00
Filip Dutescu
c755dae6ee feat(leptos-config): kebab-case via serde's rename_all (#1308)
Currently, `leptos::LeptosOptions` are deserialized (and serialized)
to `snake_case`, since, by default, `serde` uses the name of the field
as-is. The options given via `.toml` files are given using `kebab-case`.
This behaviour leads to problems if you wish to use
`leptos::LeptosOptions` as the type of a field in your own config,
which uses the `kebab-case` syntax, as it will not provide any values
to that field.

These changes switch out the previous mechanism of manually replacing
the `-` character to the `_` character in order for serialization to
work. In its place, it uses `serde`'s `#[serde(rename_all = ...)]`
macro to handle the naming convention.

In case other naming conventions are used, a workaround would be to
define a user-owned mirror struct of `leptos::LeptosOptions`, which
would contain the required fields, deserialize it as desired and'
convert it to the latter via, for example, the `From<T>` trait.

Closes: #1295

Signed-off-by: Filip Dutescu <filip@hucksy.dev>
2023-07-11 09:36:55 -04:00
Greg Johnston
b67d51e019 docs: remove empty chapter from book 2023-07-10 07:38:12 -04:00
Joseph Cruz
7a34d6026f refactor(ci): improve the organization of cargo make tasks (#1320)
* refactor(cargo-make): extract node

* refactor(cargo-make): extract lint

* refactor(counters_stable): remove redundant tasks

* docs(cargo-make): remove descriptions

* refactor(counters_stable): streamline stages
2023-07-09 20:47:10 -04:00
Mahesh Bansod
548eac8e60 docs: typo & punctuation (#1316) 2023-07-09 20:35:04 -04:00
Mahesh Bansod
05ac8e861f docs: typo (#1315) 2023-07-09 20:34:36 -04:00
Martinez
7a4d475cca docs: update warnings to remove mention of csr as a default feature (#1313) 2023-07-09 20:32:25 -04:00
Greg Johnston
eea8e60518 docs: clarify WASM target (#1318) 2023-07-09 17:06:41 -04:00
Joseph Cruz
f6a272498d test(counters_stable/wasm): enter count (#1307) 2023-07-08 12:07:11 -04:00
Ari Seyhun
aef7c4ce8e perf: use lazy thread local for regex in router match_optionals (#1309) 2023-07-08 08:47:52 -04:00
Greg Johnston
b29eb8e032 fix: <ActionForm/> should check origin correctly before doing a full-page refresh (#1304) 2023-07-08 08:00:48 -04:00
Greg Johnston
da9183f4b5 docs: fix braces in <Show/> example (#1303) 2023-07-08 06:42:27 -04:00
Greg Johnston
ae3ddcb0e6 docs: must use View (#1302) 2023-07-08 06:42:02 -04:00
Greg Johnston
c6b8f0e8ed v0.4.2 2023-07-07 15:34:56 -04:00
g-re-g
bab9f40a81 fix: rework diff functionality for <For/> (#1296) 2023-07-07 15:32:26 -04:00
webmstk
c2cfdf3678 docs: fixed typo in parent-child doc (#1300) 2023-07-07 13:59:08 -04:00
Joseph Cruz
8967eadc02 test(counters_stable): add wasm testing (#1278)
* test(counters_stable/wasm): view counters > counts

* test(counters_stable/wasm): view counters > title

* test(counters_stable/wasm): add counter

* test(counters_stable/wasm): add 1k counters

* clear(counters_stable/wasm): clear counters

* test(counters_stable/wasm): increment counters

* test(counters_stable/wasm): decrement counter

* test(counters_stable/wasm): remove counter
2023-07-07 13:07:27 -04:00
Greg Johnston
4cc65f837f chore: add mdbook in flake (#1299)
* nix-flake: use follows for `rust-overlay` (..)

This removes the extra nixpkgs dependency by re-using the nixpkgs
already defined.

https://nixos.wiki/wiki/Flakes
https://web.archive.org/web/20230621091703/https://nixos.wiki/wiki/Flakes

> # The `follows` keyword in inputs is used for inheritance.
> # Here, `inputs.nixpkgs` of sops-nix is kept consistent with the `inputs.nixpkgs` of
> # the current flake, to avoid problems caused by different versions of nixpkgs.

* nix-flake: use nix overlays for rustc/cargo (..)

This allows the same nightly rust version to be used across the
flake (vs. strictly in the `devShell`).

* nix-flake: add `mdbook` to the development shell (..)

bumped `nixpkgs` to the latest unstable to pull in mdbook version `0.4.30`.

* nix-flake: expose all rust tools in dev environment (..)

The `oxalica / rust-overlay` docs expose all tools in the development
environment, so we should do the same:

https://github.com/oxalica/rust-overlay#use-in-devshell-for-nix-develop

---------

Co-authored-by: Jay Querie <jay@querie.cc>
2023-07-07 12:59:18 -04:00
Dessalines
22706e7371 docs: adding instructions to add a tailwind plugin to examples. (#1293) 2023-07-07 12:35:24 -04:00
webmstk
9f9302662c docs/examples: make error handling example more obvious for Chrome users (#1292) 2023-07-07 12:34:57 -04:00
Greg Johnston
6b90e1babd examples: add 404 support in Actix examples (closes #1031) (#1291) 2023-07-06 10:35:37 -04:00
sjud
7e540a8f49 feat: support Axum extractors with state other than () (#1275)
This requires state to be provided via context using a special handler, but allows for extractors that use this state, rather than only `()`, as previously.
2023-07-05 20:40:29 -04:00
Greg Johnston
f06ffd72aa fix: use once_cell::OnceCell rather than std::OnceCell (#1288) 2023-07-05 17:16:51 -04:00
Greg Johnston
83d3d7579c fix: issue with class hydration not removing classes correctly (closes #1286) (#1287) 2023-07-05 12:00:27 -04:00
Greg Johnston
39edb6eb45 fix: untracked read in <Redirect/> (#1280) 2023-07-04 11:52:13 -04:00
Greg Johnston
d81c1a929e fix: duplicate text nodes during <For/> hydration (closes #1279) (#1281) 2023-07-04 11:50:59 -04:00
Greg Johnston
f69c28df18 fix: improved diagnostics about non-reactive signal access (#1277) 2023-07-03 19:37:15 -04:00
Greg Johnston
66f54e7f1a docs: add docs on responses/redirects and clarification re: Axum State(_) extractors (#1272) 2023-07-03 09:58:02 -04:00
Greg Johnston
81e416b085 fix: error messages in dyn_classes (#1270) 2023-07-03 09:57:50 -04:00
Marc-Stefan Cassola
a5f73b441c feat: added watch helper (#1262) 2023-07-03 09:29:40 -04:00
Greg Johnston
0f1ebccad5 fix: clearing <For/> that has a previous sibling in release mode (fixes #1258) (#1267) 2023-07-02 17:27:39 -04:00
Greg Johnston
2f01df6185 fix: HtmlElement::dyn_classes() when adding classes (#1265) 2023-07-02 17:27:24 -04:00
martin frances
c4982319fe chore: ran cargo clippy --fix and reviewed changes. (#1259) 2023-07-02 17:27:14 -04:00
Michael Zimmermann
8fb4e88439 feat: implement PartialEq on ServerFnError (#1260)
This allows returning it in a memo.
2023-07-02 17:22:06 -04:00
Greg Johnston
e821efca07 chore: new cargo fmt (#1266) 2023-07-02 17:01:39 -04:00
Sridhar Ratnakumar
568f7b21ae example/readme: Link to 'VS Browser' ext; format. (#1261) 2023-07-02 16:56:59 -04:00
Greg Johnston
d3c0f5320c docs: update 02_getting_started.md (#1256) 2023-06-30 17:26:33 -04:00
Greg Johnston
5adc88bf50 fix: hot-reloading view marker line number (#1255) 2023-06-30 14:03:54 -04:00
Greg Johnston
67300adf41 fix: regression in ability to use signals directly in the view in stable (#1254) 2023-06-30 11:59:29 -04:00
afiqzx
4a3a67bf37 feat: add fallback support for workspace in get_config_from_str (#1249) 2023-06-30 10:44:27 -04:00
Dương
8150847218 test(router_example): add playwright tests (#1247)
* implemented e2e tests for router example

* chore(router_example): cleanup e2e/package.json
2023-06-30 10:40:33 -04:00
Greg Johnston
8cb95b4646 docs: update server fn docs (#1252) 2023-06-30 10:40:06 -04:00
Joseph Cruz
df4ce904a0 test(counters_stable): add missing e2e tests (#1251)
* test(counters_stable): remove unused ids

* test(counters_stable): enter count

* refactor(counters_stable/e2e): improve test names

* refactor(counters_stable): move page object

* refactor(counters_stable/e2e): target nth counter

* test(counters_stable): remove counter

* refactor(counters_stable/e2e): change description
2023-06-30 08:52:48 -04:00
Ari Seyhun
1cc3a43268 chore: remove unused variable warnings with ssr props (#1244) 2023-06-30 08:05:20 -04:00
Greg Johnston
d5a862a406 v0.4.0 (#1250) 2023-06-30 07:51:07 -04:00
158 changed files with 3015 additions and 1734 deletions

View File

@@ -40,7 +40,7 @@ jobs:
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
fail-fast: false
uses: ./.github/workflows/run-example-task.yml
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "check"

View File

@@ -1,46 +0,0 @@
name: Check
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
jobs:
test:
name: Run `cargo check` ${{ matrix.os }} (using rustc ${{ matrix.rust }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
rust:
- nightly
os:
- ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
override: true
components: rustfmt
- name: Add wasm32-unknown-unknown
run: rustup target add wasm32-unknown-unknown
- name: Setup cargo-make
uses: davidB/rust-cargo-make@v1
- name: Cargo generate-lockfile
run: cargo generate-lockfile
- uses: Swatinem/rust-cache@v2
- name: Run cargo check on all libraries
run: cargo make --profile=github-actions check

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
matrix-job:
name: CI
strategy:
matrix:
directory:
[
integrations/actix,
integrations/axum,
integrations/viz,
integrations/utils,
leptos,
leptos_config,
leptos_dom,
leptos_hot_reload,
leptos_macro,
leptos_reactive,
leptos_server,
meta,
router,
server_fn,
server_fn/server_fn_macro_default,
server_fn_macro,
]
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "ci"

View File

@@ -1,34 +0,0 @@
name: Format
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Run rustfmt
runs-on: ${{ matrix.os }}
strategy:
matrix:
rust:
- nightly
os:
- ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
override: true
components: rustfmt
- name: Run Rustfmt
run: cargo fmt -- --check

View File

@@ -1,4 +1,4 @@
name: Run Example Task
name: Run Task
on:
workflow_call:
@@ -27,9 +27,9 @@ jobs:
steps:
# Setup environment
- name: Install playwright browser dependencies
run: |
run: |
sudo apt-get update
sudo apt-get install libegl1 libvpx7 libevent-2.1-7 libopus0 libopengl0 libwoff1 libharfbuzz-icu0 libgstreamer-plugins-base1.0-0 libgstreamer-gl1.0-0 libhyphen0 libmanette-0.2-0 libgles2 gstreamer1.0-libav
sudo apt-get install libegl1 libvpx7 libevent-2.1-7 libopus0 libopengl0 libwoff1 libharfbuzz-icu0 libgstreamer-plugins-base1.0-0 libgstreamer-gl1.0-0 libhyphen0 libmanette-0.2-0 libgles2 gstreamer1.0-libav
- uses: actions/checkout@v3
@@ -84,7 +84,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
# Verify project
# Run Cargo Make Task
- name: ${{ inputs.cargo_make_task }}
run: |
if [ "${{ inputs.directory }}" = "INTERNAL" ]; then

View File

@@ -1,46 +0,0 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
jobs:
test:
name: Run tests ${{ matrix.os }} (using rustc ${{ matrix.rust }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
rust:
- nightly
os:
- ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
override: true
components: rustfmt
- name: Add wasm32-unknown-unknown
run: rustup target add wasm32-unknown-unknown
- name: Setup cargo-make
uses: davidB/rust-cargo-make@v1
- name: Cargo generate-lockfile
run: cargo generate-lockfile
- uses: Swatinem/rust-cache@v2
- name: Run tests with all features
run: cargo make --profile=github-actions test

View File

@@ -41,7 +41,7 @@ jobs:
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
fail-fast: false
uses: ./.github/workflows/run-example-task.yml
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "verify-flow"

View File

@@ -65,7 +65,7 @@ jobs:
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
fail-fast: false
uses: ./.github/workflows/run-example-task.yml
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "verify-flow"

View File

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

View File

@@ -3,122 +3,25 @@
# cargo install --force cargo-make
############
[config]
# make tasks run at the workspace root
default_to_workspace = false
[tasks.check]
clear = true
dependencies = [
"check-all",
"check-wasm",
"check-all-release",
"check-wasm-release",
]
[tasks.check-all]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"
[tasks.check-wasm]
clear = true
dependencies = [{ name = "check-wasm", path = "leptos" }]
[tasks.check-all-release]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"
[tasks.check-wasm-release]
clear = true
dependencies = [{ name = "check-wasm-release", path = "leptos" }]
[tasks.check-examples]
clear = true
dependencies = [
{ name = "check", path = "examples/counter" },
{ name = "check", path = "examples/counter_isomorphic" },
{ name = "check", path = "examples/counters" },
{ name = "check", path = "examples/error_boundary" },
{ name = "check", path = "examples/errors_axum" },
{ name = "check", path = "examples/fetch" },
{ name = "check", path = "examples/hackernews" },
{ name = "check", path = "examples/hackernews_axum" },
{ name = "check", path = "examples/js-framework-benchmark" },
{ name = "check", path = "examples/leptos-tailwind-axum" },
{ name = "check", path = "examples/login_with_token_csr_only" },
{ name = "check", path = "examples/parent_child" },
{ name = "check", path = "examples/router" },
{ name = "check", path = "examples/session_auth_axum" },
{ name = "check", path = "examples/slots" },
{ name = "check", path = "examples/ssr_modes" },
{ name = "check", path = "examples/ssr_modes_axum" },
{ name = "check", path = "examples/tailwind" },
{ name = "check", path = "examples/tailwind_csr_trunk" },
{ name = "check", path = "examples/timer" },
{ name = "check", path = "examples/todo_app_sqlite" },
{ name = "check", path = "examples/todo_app_sqlite_axum" },
{ name = "check", path = "examples/todo_app_sqlite_viz" },
{ name = "check", path = "examples/todomvc" },
]
[env]
CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
[tasks.check-stable]
workspace = false
clear = true
dependencies = [
{ name = "check", path = "examples/counter_without_macros" },
{ name = "check", path = "examples/counters_stable" },
]
[tasks.test]
clear = true
dependencies = [
"test-all",
"test-leptos_macro-example",
"doc-leptos_macro-example",
]
[tasks.test-all]
command = "cargo"
args = ["+nightly", "test-all-features"]
install_crate = "cargo-all-features"
[tasks.test-leptos_macro-example]
description = "Tests the leptos_macro/example to check if macro handles doc comments correctly"
command = "cargo"
args = ["+nightly", "test", "--doc"]
cwd = "leptos_macro/example"
install_crate = false
[tasks.doc-leptos_macro-example]
description = "Docs the leptos_macro/example to check if macro handles doc comments correctly"
command = "cargo"
args = ["+nightly", "doc"]
cwd = "leptos_macro/example"
install_crate = false
[tasks.test-examples]
description = "Run all unit and web tests for examples"
[tasks.ci-examples]
workspace = false
cwd = "examples"
command = "cargo"
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"]
args = ["make", "ci-clean"]
[tasks.clean-examples]
description = "Clean all example projects"
workspace = false
cwd = "examples"
command = "cargo"
args = ["make", "clean-all"]
[env]
RUSTFLAGS = ""
LEPTOS_OUTPUT_NAME = "ci" # allows examples to check/build without cargo-leptos
[env.github-actions]
RUSTFLAGS = "-D warnings"
args = ["make", "clean"]

7
cargo-make/check.toml Normal file
View File

@@ -0,0 +1,7 @@
[tasks.check]
alias = "check-all"
[tasks.check-all]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"

9
cargo-make/lint.toml Normal file
View File

@@ -0,0 +1,9 @@
[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}"]

18
cargo-make/main.toml Normal file
View File

@@ -0,0 +1,18 @@
extend = [
{ path = "./check.toml" },
{ path = "./lint.toml" },
{ path = "./test.toml" },
]
[env]
RUSTFLAGS = ""
LEPTOS_OUTPUT_NAME = "ci" # allows examples to check/build without cargo-leptos
[env.github-actions]
RUSTFLAGS = "-D warnings"
[tasks.ci]
dependencies = ["lint", "test"]
[tasks.lint]
dependencies = ["check-format-flow"]

7
cargo-make/test.toml Normal file
View File

@@ -0,0 +1,7 @@
[tasks.test]
alias = "test-all"
[tasks.test-all]
command = "cargo"
args = ["+nightly", "test-all-features"]
install_crate = "cargo-all-features"

2
docs/book/book.toml Normal file
View File

@@ -0,0 +1,2 @@
[output.html.playground]
runnable = false

View File

@@ -14,24 +14,38 @@ If you dont already have it installed, you can install Trunk by running
cargo install trunk
```
Create a basic Rust binary project
Create a basic Rust project
```bash
cargo init leptos-tutorial
```
> 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
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
```bash
cargo add leptos --features=csr,nightly
```
Or you can leave off `nighly` if you're using stable Rust
```bash
cargo add leptos --features=csr
```
> 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
>
> ```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.
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
Make sure you've added the `wasm32-unknown-unknown` target so that Rust can compile your code to WebAssembly to run in the browser.
```bash
cargo add leptos
rustup target add wasm32-unknown-unknown
```
Create a simple `index.html` in the root of the `leptos-tutorial` directory

View File

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

View File

@@ -23,7 +23,6 @@
- [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 at 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 it 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 replacing 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 replace 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 explain 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 explained 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

@@ -62,6 +62,8 @@ pub async fn axum_extract(cx: Scope) -> Result<String, ServerFnError> {
These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same `extract()` pattern.
> Note: For now, the Axum `extract` function only supports extractors for which the state is `()`, i.e., you can't yet use it to extract `State(_)`. You can access `State(_)` by using a custom handler that extracts the state and then provides it via context. [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
## A Note about Data-Loading Patterns
Because Actix and (especially) Axum are built on the idea of a single round-trip HTTP request and response, you typically run extractors near the “top” of your application (i.e., before you start rendering) and use the extracted data to determine how that should be rendered. Before you render a `<button>`, you load all the data your app could need. And any given route handler needs to know all the data that will need to be extracted by that route.

View File

@@ -1 +1,74 @@
# Responses and Redirects
Extractors provide an easy way to access request data inside server functions. Leptos also provides a way to modify the HTTP response, using the `ResponseOptions` type (see docs for [Actix](https://docs.rs/leptos_actix/latest/leptos_actix/struct.ResponseOptions.html) or [Axum](https://docs.rs/leptos_axum/latest/leptos_axum/struct.ResponseOptions.html)) types and the `redirect` helper function (see docs for [Actix](https://docs.rs/leptos_actix/latest/leptos_actix/fn.redirect.html) or [Axum](https://docs.rs/leptos_axum/latest/leptos_axum/fn.redirect.html)).
## `ResponseOptions`
`ResponseOptions` is provided via context during the initial server rendering response and during any subsequent server function call. It allows you to easily set the status code for the HTTP response, or to add headers to the HTTP response, e.g., to set cookies.
```rust
#[server(TeaAndCookies)]
pub async fn tea_and_cookies(cx: Scope) -> Result<(), ServerFnError> {
use actix_web::{cookie::Cookie, http::header, http::header::HeaderValue};
use leptos_actix::ResponseOptions;
// pull ResponseOptions from context
let response = expect_context::<ResponseOptions>(cx);
// set the HTTP status code
response.set_status(StatusCode::IM_A_TEAPOT);
// set a cookie in the HTTP response
let mut cookie = Cookie::build("biscuits", "yes").finish();
if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
res.insert_header(header::SET_COOKIE, cookie);
}
}
```
## `redirect`
One common modification to an HTTP response is to redirect to another page. The Actix and Axum integrations provide a `redirect` function to make this easy to do. `redirect` simply sets an HTTP status code of `302 Found` and sets the `Location` header.
Heres a simplified example from our [`session_auth_axum` example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/auth.rs#L154-L181).
```rust
#[server(Login, "/api")]
pub async fn login(
cx: Scope,
username: String,
password: String,
remember: Option<String>,
) -> Result<(), ServerFnError> {
// pull the DB pool and auth provider from context
let pool = pool(cx)?;
let auth = auth(cx)?;
// check whether the user exists
let user: User = User::get_from_username(username, &pool)
.await
.ok_or_else(|| {
ServerFnError::ServerError("User does not exist.".into())
})?;
// check whether the user has provided the correct password
match verify(password, &user.password)? {
// if the password is correct...
true => {
// log the user in
auth.login_user(user.id);
auth.remember_user(remember.is_some());
// and redirect to the home page
leptos_axum::redirect(cx, "/");
Ok(())
}
// if not, return an error
false => Err(ServerFnError::ServerError(
"Password does not match.".to_string(),
)),
}
}
```
This server function can then be used from your application. This `redirect` works well with the progressively-enhanced `<ActionForm/>` component: without JS/WASM, the server response will redirect because of the status code and header. With JS/WASM, the `<ActionForm/>` will detect the redirect in the server function response, and use client-side navigation to redirect to the new page.

View File

@@ -74,11 +74,11 @@ In other words, if this is being compiled to WASM, it has three items; otherwise
When I load the page in the browser, I see nothing. If I open the console I see a bunch of warnings:
```
element with id 0-0-1 not found, ignoring it for hydration
element with id 0-0-2 not found, ignoring it for hydration
element with id 0-0-3 not found, ignoring it for hydration
component with id _0-0-4c not found, ignoring it for hydration
component with id _0-0-4o not found, ignoring it for hydration
element with id 0-3 not found, ignoring it for hydration
element with id 0-4 not found, ignoring it for hydration
element with id 0-5 not found, ignoring it for hydration
component with id _0-6c not found, ignoring it for hydration
component with id _0-6o not found, ignoring it for hydration
```
The WASM version of your app, running in the browser, expects to find three items; but the HTML has none.
@@ -87,6 +87,56 @@ The WASM version of your app, running in the browser, expects to find three item
Its pretty rare that you do this intentionally, but it could happen from somehow running different logic on the server and in the browser. If youre seeing warnings like this and you dont think its your fault, its much more likely that its a bug with `<Suspense/>` or something. Feel free to go ahead and open an [issue](https://github.com/leptos-rs/leptos/issues) or [discussion](https://github.com/leptos-rs/leptos/discussions) on GitHub for help.
### Mutating the DOM during rendering
This is a slightly more common way to create a client/server mismatch: updating a signal _during rendering_ in a way that mutates the view.
```rust
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (loaded, set_loaded) = create_signal(cx, false);
// create_effect only runs on the client
create_effect(cx, move |_| {
// do something like reading from localStorage
set_loaded(true);
});
move || {
if loaded() {
view! { cx, <p>"Hello, world!"</p> }.into_any()
} else {
view! { cx, <div class="loading">"Loading..."</div> }.into_any()
}
}
}
```
This one gives us the scary panic
```
panicked at 'assertion failed: `(left == right)`
left: `"DIV"`,
right: `"P"`: SSR and CSR elements have the same hydration key but different node kinds.
```
And a handy link to this page!
The problem here is that `create_effect` runs **immediately** and **synchronously**, but only in the browser. As a result, on the server, `loaded` is false, and a `<div>` is rendered. But on the browser, by the time the view is being rendered, `loaded` has already been set to `true`, and the browser is expecting to find a `<p>`.
#### Solution
You can simply tell the effect to wait a tick before updating the signal, by using something like `request_animation_frame`, which will set a short timeout and then update the signal before the next frame.
```rust
create_effect(cx, move |_| {
// do something like reading from localStorage
request_animation_frame(move || set_loaded(true));
});
```
This allows the browser to hydrate with the correct, matching state (`loaded` is `false` when it reaches the view), then immediately update it to `true` once hydration is complete.
### Not all client code can run on the server
Imagine you happily import a dependency like `gloo-net` that youve been used to using to make requests in the browser, and use it in a `create_resource` in a server-rendered app.

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 type="number" on:input=on_input/>
<input 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 type="number" on:input=on_input/>
<input 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<F>(cx: Scope) -> impl IntoView {
pub fn ButtonC(cx: Scope) -> impl IntoView {
view! { cx,
<button>"Toggle"</button>
}

View File

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

View File

@@ -0,0 +1,28 @@
[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

@@ -1,98 +0,0 @@
[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

@@ -0,0 +1,9 @@
[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,35 +1,32 @@
extend = [{ path = "../cargo-make/common.toml" }]
extend = [
{ path = "../cargo-make/clean.toml" },
{ path = "../cargo-make/lint.toml" },
{ path = "../cargo-make/node.toml" },
]
# CI Stages
[tasks.ci]
alias = "verify-flow"
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
[tasks.verify-flow]
description = "Provides pre and post hooks for verify"
dependencies = ["pre-verify", "verify", "post-verify"]
alias = "ci"
[tasks.verify]
description = "Run all quality checks and tests"
dependencies = ["check-style", "test-unit-and-e2e"]
[tasks.t]
dependencies = ["test-flow"]
[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]
[tasks.it]
alias = "integration-test"

View File

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

View File

@@ -1,22 +1,8 @@
[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"
@@ -46,8 +32,6 @@ done
'''
[tasks.test-playwright-ui]
description = "Run playwright test --ui"
category = "Test"
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
@@ -77,8 +61,6 @@ done
'''
[tasks.test-playwright-report]
description = "Run playwright show-report"
category = "Test"
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"

View File

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

View File

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

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.86"
wasm-bindgen = "=0.2.87"
serde = { version = "1", features = ["derive"] }
[features]

View File

@@ -67,24 +67,10 @@ pub fn Counters(cx: Scope) -> impl IntoView {
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<main>
<Routes>
<Route
path=""
view=|cx| {
view! { cx, <Counter/> }
}
/>
<Route
path="form"
view=|cx| {
view! { cx, <FormCounter/> }
}
/>
<Route
path="multi"
view=|cx| {
view! { cx, <MultiuserCounter/> }
}
/>
<Route path="" view=Counter/>
<Route path="form" view=FormCounter/>
<Route path="multi" view=MultiuserCounter/>
<Route path="multi" view=NotFound/>
</Routes>
</main>
</Router>
@@ -175,13 +161,9 @@ 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"/>
@@ -256,3 +238,14 @@ 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,15 +52,36 @@ cfg_if! {
App::new()
.service(counter_events)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <Counters/> })
.service(Files::new("/", site_root))
// 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()))
//.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,10 +5,23 @@ 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-test = "0.3.0"
wasm-bindgen = "0.2.87"
wasm-bindgen-test = "0.3.37"
pretty_assertions = "1.3.0"
[dev-dependencies.web-sys]
features = [
"Event",
"EventInit",
"EventTarget",
"HtmlElement",
"HtmlInputElement",
"XPathResult",
]
version = "0.3.64"

View File

@@ -1,5 +1,6 @@
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

@@ -1,8 +1,8 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./counters_page";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Add 1000 Counters", () => {
test("should increment the total count by 1K", async ({ page }) => {
test("should increase the number of counters", async ({ page }) => {
const ui = new CountersPage(page);
await Promise.all([
@@ -14,7 +14,6 @@ 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

@@ -1,8 +1,8 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./counters_page";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Add Counter", () => {
test("should increment the total count", async ({ page }) => {
test("should increase the number of counters", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
@@ -10,7 +10,6 @@ test.describe("Add Counter", () => {
await ui.addCounter();
await ui.addCounter();
await expect(ui.total).toHaveText("0");
await expect(ui.counters).toHaveText("3");
});
});

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./counters_page";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Clear Counters", () => {
test("should reset the counts", async ({ page }) => {

View File

@@ -1,8 +1,8 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./counters_page";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Decrement Count", () => {
test("should decrement the total count", async ({ page }) => {
test("should decrease the total count", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
await ui.addCounter();
@@ -12,6 +12,5 @@ test.describe("Decrement Count", () => {
await ui.decrementCount();
await expect(ui.total).toHaveText("-3");
await expect(ui.counters).toHaveText("1");
});
});

View File

@@ -0,0 +1,30 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Enter Count", () => {
test("should increase the total count", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
await ui.addCounter();
await ui.enterCount("5");
await expect(ui.total).toHaveText("5");
await expect(ui.counters).toHaveText("1");
});
test("should decrease the total count", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
await ui.addCounter();
await ui.addCounter();
await ui.addCounter();
await ui.enterCount("100");
await ui.enterCount("100", 1);
await ui.enterCount("100", 2);
await ui.enterCount("50", 1);
await expect(ui.total).toHaveText("250");
});
});

View File

@@ -5,8 +5,11 @@ export class CountersPage {
readonly addCounterButton: Locator;
readonly addOneThousandCountersButton: Locator;
readonly clearCountersButton: Locator;
readonly decrementCountButton: Locator;
readonly incrementCountButton: Locator;
readonly counterInput: Locator;
readonly decrementCountButton: Locator;
readonly removeCountButton: Locator;
readonly total: Locator;
readonly counters: Locator;
@@ -32,9 +35,15 @@ export class CountersPage {
hasText: "+1",
});
this.removeCountButton = page.locator("button", {
hasText: "x",
});
this.total = page.getByTestId("total");
this.counters = page.getByTestId("counters");
this.counterInput = page.getByRole("textbox");
}
async goto() {
@@ -52,17 +61,17 @@ export class CountersPage {
this.addOneThousandCountersButton.click();
}
async decrementCount() {
async decrementCount(index: number = 0) {
await Promise.all([
this.decrementCountButton.waitFor(),
this.decrementCountButton.click(),
this.decrementCountButton.nth(index).waitFor(),
this.decrementCountButton.nth(index).click(),
]);
}
async incrementCount() {
async incrementCount(index: number = 0) {
await Promise.all([
this.incrementCountButton.waitFor(),
this.incrementCountButton.click(),
this.incrementCountButton.nth(index).waitFor(),
this.incrementCountButton.nth(index).click(),
]);
}
@@ -72,4 +81,18 @@ export class CountersPage {
this.clearCountersButton.click(),
]);
}
async enterCount(count: string, index: number = 0) {
await Promise.all([
this.counterInput.nth(index).waitFor(),
this.counterInput.nth(index).fill(count),
]);
}
async removeCounter(index: number = 0) {
await Promise.all([
this.removeCountButton.nth(index).waitFor(),
this.removeCountButton.nth(index).click(),
]);
}
}

View File

@@ -1,8 +1,8 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./counters_page";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Increment Count", () => {
test("should increment the total count", async ({ page }) => {
test("should increase the total count", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
await ui.addCounter();
@@ -12,6 +12,5 @@ test.describe("Increment Count", () => {
await ui.incrementCount();
await expect(ui.total).toHaveText("3");
await expect(ui.counters).toHaveText("1");
});
});

View File

@@ -0,0 +1,17 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Remove Counter", () => {
test("should decrement the number of counters", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
await ui.addCounter();
await ui.addCounter();
await ui.addCounter();
await ui.removeCounter(1);
await expect(ui.counters).toHaveText("2");
});
});

View File

@@ -1,8 +1,8 @@
import { test, expect } from "@playwright/test";
import { CountersPage } from "./counters_page";
import { CountersPage } from "./fixtures/counters_page";
test.describe("View Counters", () => {
test("should_see_the_title", async ({ page }) => {
test("should see the title", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();

View File

@@ -0,0 +1,109 @@
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,3 +1,4 @@
use counters_stable::Counters;
use leptos::*;
fn main() {
@@ -5,108 +6,3 @@ 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 id="total" data-testid="total">{move ||
counters.get()
.iter()
.map(|(_, (count, _))| count.get())
.sum::<i32>()
.to_string()
}</span>
" from "
<span id="counters" 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>{move || value.get().to_string()}</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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,112 @@
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

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

View File

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,22 @@
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 type="number" on:input=on_input/>
<input 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,10 +14,6 @@ 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<()> {
@@ -33,17 +29,34 @@ cfg_if! {
let site_root = &leptos_options.site_root;
App::new()
.service(css)
.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))
.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()))
//.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};

21
examples/router/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
Generated by Cargo
# will have compiled files and executables
/target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# Support playwright testing
node_modules/
test-results/
end2end/playwright-report/
playwright/.cache/
pnpm-lock.yaml
# Support trunk
dist

View File

@@ -1,4 +1,8 @@
extend = { path = "../cargo-make/main.toml" }
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/trunk_server.toml" },
{ path = "../cargo-make/playwright-test.toml" },
]
[tasks.build]
command = "cargo"

36
examples/router/e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "e2e",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@playwright/test": {
"version": "1.35.1",
"dev": true,
"requires": {
"@types/node": "*",
"fsevents": "2.3.2",
"playwright-core": "1.35.1"
}
},
"@types/node": {
"version": "20.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz",
"integrity": "sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"playwright-core": {
"version": "1.35.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz",
"integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==",
"dev": true
}
}
}

View File

@@ -0,0 +1,7 @@
{
"private": "true",
"scripts": {},
"devDependencies": {
"@playwright/test": "^1.35.1"
}
}

View File

@@ -0,0 +1,77 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 10 : 10,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "list",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
// {
// name: "firefox",
// use: { ...devices["Desktop Firefox"] },
// },
// {
// name: "webkit",
// use: { ...devices["Desktop Safari"] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: "cd ../ && trunk serve",
// url: "http://127.0.0.1:8080",
// reuseExistingServer: false, //!process.env.CI,
// },
});

View File

@@ -0,0 +1,30 @@
import { test, expect } from "@playwright/test";
test.describe("Test Router example", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
const links = [
{ label: "Bill Smith", url: "/0" },
{ label: "Tim Jones", url: "/1" },
{ label: "Sally Stevens", url: "/2" },
{ label: "About", url: "/about" },
{ label: "Settings", url: "/settings" },
];
links.forEach(({ label, url }) => {
test(`Can navigate to ${label}`, async ({ page }) => {
await page.getByRole("link", { name: label }).click();
await expect(page.getByRole("heading", { name: label })).toBeVisible();
await expect(page).toHaveURL(url);
});
});
test("Can redirect to home", async ({ page }) => {
await page.getByRole("link", { name: "About" }).click();
await page.getByRole("link", { name: "Redirect to Home" }).click();
await expect(page).toHaveURL("/");
});
});

View File

@@ -0,0 +1,11 @@
{
"private": true,
"scripts": {
"start-server": "trunk serve",
"e2e": "cargo make test-playwright",
"e2e:auto-start": "start-server-and-test start-server http://127.0.0.1:8080 e2e"
},
"devDependencies": {
"start-server-and-test": "^2.0.0"
}
}

View File

@@ -27,6 +27,11 @@ pub fn App(cx: Scope) -> impl IntoView {
view=Post
ssr=SsrMode::Async
/>
<Route
path="/*any"
view=NotFound
/>
</Routes>
</main>
</Router>
@@ -182,3 +187,14 @@ 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,12 +24,11 @@ async fn main() -> std::io::Result<()> {
App::new()
.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))
.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()))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
@@ -37,6 +36,18 @@ 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,6 +30,25 @@ 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

@@ -52,8 +52,9 @@ If you're using VS Code, add the following to your `settings.json`
Install [Tailwind CSS Intellisense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss).
Install "VS Browser" extension, a browser at the right window.
Allow vscode Ports forward: 3000, 3001.
Install [VS Browser](https://marketplace.visualstudio.com/items?itemName=Phu1237.vs-browser) extension (allows you to open a browser at the right window.
Allow vscode Ports forward: 3000, 3001.
## Notes about Tooling

View File

@@ -47,14 +47,32 @@ cfg_if! {
App::new()
.service(css)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <TodoApp/> })
.service(Files::new("/", site_root))
.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()))
//.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

@@ -4,6 +4,14 @@ use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::{Connection, SqliteConnection};
@@ -11,20 +19,6 @@ cfg_if! {
pub async fn db() -> Result<SqliteConnection, ServerFnError> {
Ok(SqliteConnection::connect("sqlite:Todos.db").await?)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
} else {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
}
}
@@ -92,10 +86,8 @@ pub fn TodoApp(cx: Scope) -> impl IntoView {
</header>
<main>
<Routes>
<Route path="" view=|cx| view! {
cx,
<Todos/>
}/>
<Route path="" view=Todos/>
<Route path="/*any" view=NotFound/>
</Routes>
</main>
</Router>
@@ -200,3 +192,14 @@ 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> }
}

View File

@@ -5,6 +5,14 @@ use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::{Connection, SqliteConnection};
@@ -13,20 +21,6 @@ cfg_if! {
pub async fn db() -> Result<SqliteConnection, ServerFnError> {
Ok(SqliteConnection::connect("sqlite:Todos.db").await?)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
} else {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
}
}

View File

@@ -5,6 +5,14 @@ use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::{Connection, SqliteConnection};
@@ -13,20 +21,6 @@ cfg_if! {
pub async fn db() -> Result<SqliteConnection, ServerFnError> {
Ok(SqliteConnection::connect("sqlite:Todos.db").await?)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
} else {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
}
}

26
flake.lock generated
View File

@@ -38,11 +38,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1681920287,
"narHash": "sha256-+/d6XQQfhhXVfqfLROJoqj3TuG38CAeoT6jO1g9r1k0=",
"lastModified": 1687898314,
"narHash": "sha256-B4BHon3uMXQw8ZdbwxRK1BmxVOGBV4viipKpGaIlGwk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "645bc49f34fa8eff95479f0345ff57e55b53437e",
"rev": "e18dc963075ed115afb3e312b64643bf8fd4b474",
"type": "github"
},
"original": {
@@ -52,22 +52,6 @@
"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",
@@ -78,7 +62,9 @@
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1682043560,

View File

@@ -4,6 +4,7 @@
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";
};
@@ -22,7 +23,8 @@
openssl
pkg-config
cacert
(rust-bin.selectLatestNightlyWith( toolchain: toolchain.default.override {
mdbook
(rust-bin.selectLatestNightlyWith(toolchain: toolchain.default.override {
extensions= [ "rust-src" "rust-analyzer" ];
targets = [ "wasm32-unknown-unknown" ];
}))

View File

@@ -19,3 +19,6 @@ serde_json = "1"
parking_lot = "0.12.1"
regex = "1.7.0"
tracing = "0.1.37"
[features]
nonce = ["leptos/nonce"]

View File

@@ -0,0 +1,4 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.check-format]
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }

View File

@@ -223,26 +223,30 @@ pub fn handle_server_fns_with_context(
let res_options =
use_context::<ResponseOptions>(cx).unwrap();
let mut res: HttpResponseBuilder;
let mut res: HttpResponseBuilder =
HttpResponse::Ok();
let res_parts = res_options.0.write();
if accept_header == Some("application/json")
|| accept_header
== Some("application/x-www-form-urlencoded")
|| accept_header == Some("application/cbor")
// 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")
{
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");
// 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");
}
};
// Override StatusCode if it was set in a Resource or Element
if let Some(status) = res_parts.status {
@@ -288,7 +292,12 @@ pub fn handle_server_fns_with_context(
} else {
HttpResponse::BadRequest().body(format!(
"Could not find a server function at the route {:?}. \
\n\nIt's likely that you need to call \
\n\nIt's likely that either
1. The API prefix you specify in the `#[server]` \
macro doesn't match the prefix at which your server \
function handler is mounted, or \n2. You are on a \
platform that doesn't support automatic server \
function registration and you need to call \
ServerFn::register_explicit() on the server function \
type, somewhere in your `main` function.",
req.path()
@@ -724,6 +733,8 @@ fn provide_contexts(
provide_context(cx, res_options);
provide_context(cx, req.clone());
provide_server_redirect(cx, move |path| redirect(cx, path));
#[cfg(feature = "nonce")]
leptos::nonce::provide_nonce(cx);
}
fn leptos_corrected_path(req: &HttpRequest) -> String {
@@ -788,8 +799,11 @@ async fn build_stream_response(
// wait for any blocking resources to load before pulling metadata
let first_app_chunk = stream.next().await.unwrap_or_default();
let (head, tail) =
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
let (head, tail) = html_parts_separated(
cx,
options,
use_context::<MetaContext>(cx).as_ref(),
);
let mut stream = Box::pin(
futures::stream::once(async move { head.clone() })

View File

@@ -19,6 +19,9 @@ leptos_integration_utils = { workspace = true }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12.1"
tokio-util = {version = "0.7.7", features = ["rt"] }
tokio-util = { version = "0.7.7", features = ["rt"] }
tracing = "0.1.37"
once_cell = "1.17"
[features]
nonce = ["leptos/nonce"]

View File

@@ -0,0 +1,4 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.check-format]
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }

View File

@@ -335,9 +335,14 @@ async fn handle_server_fns_inner(
Response::builder().status(StatusCode::BAD_REQUEST).body(
Full::from(format!(
"Could not find a server function at the route \
{fn_name}. \n\nIt's likely that you need to call \
{fn_name}. \n\nIt's likely that either
1. The API prefix you specify in the `#[server]` \
macro doesn't match the prefix at which your server \
function handler is mounted, or \n2. You are on a \
platform that doesn't support automatic server \
function registration and you need to call \
ServerFn::register_explicit() on the server function \
type, somewhere in your `main` function."
type, somewhere in your `main` function.",
)),
)
}
@@ -688,8 +693,11 @@ async fn forward_stream(
let mut shell = Box::pin(bundle);
let first_app_chunk = shell.next().await.unwrap_or_default();
let (head, tail) =
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
let (head, tail) = html_parts_separated(
cx,
options,
use_context::<MetaContext>(cx).as_ref(),
);
_ = tx.send(head).await;
_ = tx.send(first_app_chunk).await;
@@ -823,6 +831,8 @@ fn provide_contexts(
provide_context(cx, extractor);
provide_context(cx, default_res_options);
provide_server_redirect(cx, move |path| redirect(cx, path));
#[cfg(feature = "nonce")]
leptos::nonce::provide_nonce(cx);
}
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
@@ -1276,18 +1286,24 @@ impl ExtractorHelper {
}
}
pub async fn extract<F, T, U>(&self, f: F) -> Result<U, T::Rejection>
pub async fn extract<F, T, U, S>(
&self,
f: F,
s: S,
) -> Result<U, T::Rejection>
where
F: Extractor<T, U>,
T: std::fmt::Debug + Send + FromRequestParts<()> + 'static,
S: Sized,
F: Extractor<T, U, S>,
T: std::fmt::Debug + Send + FromRequestParts<S> + 'static,
T::Rejection: std::fmt::Debug + Send + 'static,
{
let mut parts = self.parts.lock().await;
let data = T::from_request_parts(&mut parts, &()).await?;
let data = T::from_request_parts(&mut parts, &s).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?
@@ -1315,37 +1331,80 @@ impl<B> From<Request<B>> for ExtractorHelper {
/// .map_err(|e| ServerFnError::ServerError("Could not extract method and query...".to_string()))
/// }
/// ```
///
/// > Note: For now, the Axum `extract` function only supports extractors for
/// which the state is `()`, i.e., you can't yet use it to extract `State(_)`.
/// You can access `State(_)` by using a custom handler that extracts the state
/// and then provides it via context.
/// [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
#[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)
.extract(f, state)
.await
}
pub trait Extractor<T, U>
pub trait Extractor<T, U, S>
where
T: FromRequestParts<()>,
S: Sized,
T: FromRequestParts<S>,
{
fn call(self, args: T) -> Pin<Box<dyn Future<Output = U>>>;
}
macro_rules! factory_tuple ({ $($param:ident)* } => {
impl<Func, Fut, U, $($param,)*> Extractor<($($param,)*), U> for Func
impl<Func, Fut, U, S, $($param,)*> Extractor<($($param,)*), U, S> for Func
where
$($param: FromRequestParts<()> + Send,)*
$($param: FromRequestParts<S> + Send,)*
Func: FnOnce($($param),*) -> Fut + 'static,
Fut: Future<Output = U> + 'static,
S: Sized + Send + Sync,
{
#[inline]
#[allow(non_snake_case)]

View File

@@ -0,0 +1,4 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.check-format]
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }

View File

@@ -1,18 +1,18 @@
use futures::{Stream, StreamExt};
use leptos::{use_context, RuntimeId, ScopeId};
use leptos::{nonce::use_nonce, use_context, RuntimeId, Scope, ScopeId};
use leptos_config::LeptosOptions;
use leptos_meta::MetaContext;
extern crate tracing;
#[tracing::instrument(level = "trace", fields(error), skip_all)]
fn autoreload(options: &LeptosOptions) -> String {
fn autoreload(nonce_str: &str, options: &LeptosOptions) -> String {
let site_ip = &options.site_addr.ip().to_string();
let reload_port = options.reload_port;
match std::env::var("LEPTOS_WATCH").is_ok() {
true => format!(
r#"
<script crossorigin="">(function () {{
<script crossorigin=""{nonce_str}>(function () {{
{}
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
ws.onmessage = (ev) => {{
@@ -42,6 +42,8 @@ fn autoreload(options: &LeptosOptions) -> String {
false => "".to_string(),
}
}
#[deprecated = "Use html_parts_separated."]
#[tracing::instrument(level = "trace", fields(error), skip_all)]
pub fn html_parts(
options: &LeptosOptions,
@@ -58,7 +60,7 @@ pub fn html_parts(
wasm_output_name.push_str("_bg");
}
let leptos_autoreload = autoreload(options);
let leptos_autoreload = autoreload("".into(), options);
let html_metadata =
meta.and_then(|mc| mc.html.as_string()).unwrap_or_default();
@@ -80,21 +82,27 @@ pub fn html_parts(
#[tracing::instrument(level = "trace", fields(error), skip_all)]
pub fn html_parts_separated(
cx: Scope,
options: &LeptosOptions,
meta: Option<&MetaContext>,
) -> (String, &'static str) {
let pkg_path = &options.site_pkg_dir;
let output_name = &options.output_name;
let nonce = use_nonce(cx);
let nonce = nonce
.as_ref()
.map(|nonce| format!(" nonce=\"{nonce}\""))
.unwrap_or_default();
// 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
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
// 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.
let mut wasm_output_name = output_name.clone();
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
if std::option_env!("LEPTOS_OUTPUT_NAME").is_none() {
wasm_output_name.push_str("_bg");
}
let leptos_autoreload = autoreload(options);
let leptos_autoreload = autoreload(&nonce, options);
let html_metadata =
meta.and_then(|mc| mc.html.as_string()).unwrap_or_default();
@@ -109,9 +117,9 @@ pub fn html_parts_separated(
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
{head}
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js">
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin="">
<script type="module">import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js"{nonce}>
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin=""{nonce}>
<script type="module"{nonce}>import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
{leptos_autoreload}
"#
);
@@ -133,15 +141,14 @@ pub async fn build_async_response(
}
let cx = leptos::Scope { runtime, id: scope };
let (head, tail) =
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
let (head, tail) = html_parts_separated(
cx,
options,
use_context::<MetaContext>(cx).as_ref(),
);
// in async, we load the meta content *now*, after the suspenses have resolved
let meta = use_context::<MetaContext>(cx);
let head_meta = meta
.as_ref()
.map(|meta| meta.dehydrate())
.unwrap_or_default();
let body_meta = meta
.as_ref()
.and_then(|meta| meta.body.as_string())
@@ -149,5 +156,5 @@ pub async fn build_async_response(
runtime.dispose();
format!("{head}{head_meta}</head><body{body_meta}>{buf}{tail}")
format!("{head}</head><body{body_meta}>{buf}{tail}")
}

View File

@@ -19,3 +19,6 @@ leptos_integration_utils = { workspace = true }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12.1"
[features]
nonce = ["leptos/nonce"]

View File

@@ -0,0 +1,4 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.check-format]
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }

View File

@@ -312,10 +312,17 @@ async fn handle_server_fns_inner(
.body(Body::from(format!(
"Could not find a server function at the \
route {fn_name}. \n\nIt's likely that \
you need to call \
either
1. The API prefix you specify in the \
`#[server]` macro doesn't match the \
prefix at which your server function \
handler is mounted, or \n2. You are on a \
platform that doesn't support automatic \
server function registration and you \
need to call \
ServerFn::register_explicit() on the \
server function type, somewhere in your \
`main` function."
`main` function.",
)))
}
.expect("could not build Response");
@@ -648,8 +655,11 @@ async fn forward_stream(
mut tx: Sender<String>,
) {
let cx = Scope { runtime, id: scope };
let (head, tail) =
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
let (head, tail) = html_parts_separated(
cx,
options,
use_context::<MetaContext>(cx).as_ref(),
);
_ = tx.send(head).await;
let mut shell = Box::pin(bundle);
@@ -786,6 +796,8 @@ fn provide_contexts(
provide_context(cx, req_parts);
provide_context(cx, default_res_options);
provide_server_redirect(cx, move |path| redirect(cx, path));
#[cfg(feature = "nonce")]
leptos::nonce::provide_nonce(cx);
}
/// Returns a Viz [Handler](viz::Handler) that listens for a `GET` request and tries

View File

@@ -13,16 +13,16 @@ cfg-if = "1"
leptos_dom = { workspace = true }
leptos_macro = { workspace = true }
leptos_reactive = { workspace = true }
leptos_server = { workspace = true}
leptos_server = { workspace = true }
leptos_config = { workspace = true }
tracing = "0.1"
typed-builder = "0.14"
server_fn = { workspace = true}
server_fn = { workspace = true }
web-sys = { version = "0.3.63", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
[dev-dependencies]
leptos = { path = "."}
leptos = { path = "." }
[features]
default = ["serde"]
@@ -58,9 +58,18 @@ serde-lite = ["leptos_reactive/serde-lite"]
miniserde = ["leptos_reactive/miniserde"]
rkyv = ["leptos_reactive/rkyv"]
tracing = ["leptos_macro/tracing"]
nonce = ["leptos_dom/nonce"]
[package.metadata.cargo-all-features]
denylist = ["nightly", "tracing", "template_macro", "rustls", "default-tls", "web-sys", "wasm-bindgen"]
denylist = [
"nightly",
"tracing",
"template_macro",
"rustls",
"default-tls",
"web-sys",
"wasm-bindgen",
]
skip_feature_sets = [
[
"csr",

View File

@@ -1,3 +1,14 @@
extend = { path = "../cargo-make/main.toml" }
[tasks.check]
clear = true
dependencies = [
"check-all",
"check-wasm",
"check-release",
"check-wasm-release",
]
[tasks.check-wasm]
clear = true
dependencies = ["check-hydrate", "check-csr"]
@@ -43,3 +54,7 @@ args = [
"--features=csr",
"--target=wasm32-unknown-unknown",
]
[tasks.check-release]
command = "cargo"
args = ["check", "--release"]

View File

@@ -58,13 +58,15 @@ where
F: Fn(Scope, RwSignal<Errors>) -> IV + 'static,
IV: IntoView,
{
_ = HydrationCtx::next_component();
let before_children = HydrationCtx::next_component("e");
let errors: RwSignal<Errors> = create_rw_signal(cx, Errors::default());
provide_context(cx, errors);
// Run children so that they render and execute resources
let children = children(cx);
HydrationCtx::continue_from(before_children);
#[cfg(all(debug_assertions, feature = "hydrate"))]
{

View File

@@ -84,11 +84,18 @@
//! from the server to the client.
//! - `serde-lite` In SSR/hydrate mode, uses [serde-lite](https://docs.rs/serde-lite/latest/serde_lite/) to serialize resources and send them
//! from the server to the client.
//! - `rkyv` In SSR/hydrate mode, uses [rkyv](https://docs.rs/rkyv/latest/rkyv/) to serialize resources and send them
//! from the server to the client.
//! - `miniserde` In SSR/hydrate mode, uses [miniserde](https://docs.rs/miniserde/latest/miniserde/) to serialize resources and send them
//! from the server to the client.
//! - `tracing` Adds additional support for [`tracing`](https://docs.rs/tracing/latest/tracing/) to components.
//! - `default-tls` Use default native TLS support. (Only applies when using server functions with a non-WASM client like a desktop app.)
//! - `rustls` Use `rustls`. (Only applies when using server functions with a non-WASM client like a desktop app.)
//! - `template_macro` Enables the [`template!`](leptos_macro::template) macro, which offers faster DOM node creation for some use cases in `csr`.
//!
//! **Important Note:** You must enable one of `csr`, `hydrate`, or `ssr` to tell Leptos
//! which mode your app is operating in.
//! which mode your app is operating in. You should only enable one of these per build target,
//! i.e., you should not have both `hydrate` and `ssr` enabled for your server binary, only `ssr`.
//!
//! # A Simple Counter
//!
@@ -157,9 +164,10 @@ pub use leptos_dom::{
set_interval_with_handle, set_timeout, set_timeout_with_handle,
window_event_listener, window_event_listener_untyped,
},
html, log, math, mount_to, mount_to_body, svg, warn, window, Attribute,
Class, CollectView, Errors, Fragment, HtmlElement, IntoAttribute,
IntoClass, IntoProperty, IntoStyle, IntoView, NodeRef, Property, View,
html, log, math, mount_to, mount_to_body, nonce, svg, warn, window,
Attribute, Class, CollectView, Errors, Fragment, HtmlElement,
IntoAttribute, IntoClass, IntoProperty, IntoStyle, IntoView, NodeRef,
Property, View,
};
/// Types to make it easier to handle errors in your application.

View File

@@ -69,7 +69,9 @@ where
// provide this SuspenseContext to any resources below it
provide_context(cx, context);
let current_id = HydrationCtx::next_component();
let before = HydrationCtx::peek();
let current_id = HydrationCtx::next_component("s");
leptos::log!("<Suspense/> next_component {current_id}");
let child = DynChild::new({
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
@@ -94,7 +96,7 @@ where
// run the child; we'll probably throw this away, but it will register resource reads
//let after_original_child = HydrationCtx::peek();
let initial = {
{
// no resources were read under this, so just return the child
if context.pending_resources.get() == 0 {
HydrationCtx::continue_from(current_id.clone());
@@ -106,7 +108,7 @@ where
}
// show the fallback, but also prepare to stream HTML
else {
HydrationCtx::continue_from(current_id);
HydrationCtx::continue_from(current_id.clone());
cx.register_suspense(
context,
@@ -114,6 +116,7 @@ where
// out-of-order streaming
{
let orig_children = Rc::clone(&orig_children);
let current_id = current_id.clone();
move || {
HydrationCtx::continue_from(
current_id.clone(),
@@ -131,6 +134,7 @@ where
// in-order streaming
{
let orig_children = Rc::clone(&orig_children);
let current_id = current_id.clone();
move || {
HydrationCtx::continue_from(
current_id.clone(),
@@ -149,9 +153,7 @@ where
// return the fallback for now, wrapped in fragment identifier
fallback().into_view(cx)
}
};
initial
}
}
}
})
@@ -161,8 +163,8 @@ where
_ => unreachable!(),
};
HydrationCtx::continue_from(current_id);
HydrationCtx::next_component();
HydrationCtx::continue_from(before.clone());
_ = HydrationCtx::id();
leptos_dom::View::Suspense(current_id, core_component)
}

View File

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

Some files were not shown because too many files have changed in this diff Show More