Compare commits

...

61 Commits

Author SHA1 Message Date
benwis
9e2fb62857 0.6.8 2024-03-02 18:01:10 -08:00
Ben Wishovich
1da2fff706 Fix missed stuff (#2398) 2024-03-02 17:57:20 -08:00
Greg Johnston
9fd2987447 fix: correctly reset hydration status in islands mode Suspense (closes #2332) (#2393) 2024-03-02 11:57:35 -05:00
zroug
7996f835d0 fix: remove unnecessary trait bound PartialEq from create_owning_memo (#2394) 2024-03-02 07:27:22 -05:00
Greg Johnston
d72b12524e Merge pull request #2395 from leptos-rs/int-ax-doc 2024-03-01 20:08:18 -05:00
Greg Johnston
8e79c5be5c fix: ignore as with other doctests for now 2024-03-01 18:39:55 -05:00
Greg Johnston
de25658c36 Merge pull request #2392 from paul-hansen/fix-ci
fix(ci): "needless borrow" error and example never exiting
2024-03-01 18:37:48 -05:00
Paul Hansen
e2e35a9659 fix(ci) Wait a bit longer for server to start
It took longer than I thought in Github and barely worked, giving it a
bit more of a buffer.
2024-03-01 15:47:59 -06:00
Paul Hansen
bf1ba589c5 fix(ci): Another attempt to fix hanging example 2024-03-01 15:41:22 -06:00
Sam Judelson
f70ebc1289 docs: add note on how to get ResponseOptions (#2380) 2024-03-01 10:47:02 -05:00
Paul Hansen
3cab09e015 fix(ci): error_boundary example never exiting 2024-03-01 09:21:58 -06:00
Paul Hansen
b431315f7c fix(ci): "needless borrow" error 2024-03-01 09:21:58 -06:00
Baptiste
5b40881e77 fix: specify path to wasm_bindgen in island macro (#2387) 2024-03-01 10:15:19 -05:00
benwis
59d3cce3be 0.6.7 2024-02-29 13:38:09 -08:00
Paul Hansen
6a83161368 chore: add MSRV (#2360) 2024-02-28 07:19:09 -05:00
Marc-Stefan Cassola
4b00c16cb9 added hashes generated from cargo-leptos (#2373)
* added hashes generated from cargo-leptos

* Added config option to disable frontend file name hash
2024-02-27 16:28:27 -08:00
haslersn
6d6019b956 fix: do not strip query in redirect hook when using client-side navigation (#2376) 2024-02-27 09:06:48 -08:00
Sam Judelson
3540291065 docs: Resource::read() in doc examples with Resource::get() (#2372) 2024-02-26 21:37:29 -05:00
zoomiti
4809cf473e feat: provide leptos_router::Method via context (#1808) (#2315) 2024-02-26 21:25:53 -05:00
Tadas Dailyda
aa977001c1 feat: add support for trailing slashes (closes #2154) (#2217) 2024-02-26 20:56:44 -05:00
Greg Johnston
c16189f095 Merge pull request #2362 from leptos-rs/remove-deprecation
chore(ci): fix failing CI by removing deprecation note
2024-02-24 13:05:02 -05:00
Greg Johnston
d0a013c248 chore(ci): omit a few feature flag in CI 2024-02-24 11:55:36 -05:00
Greg Johnston
531ea74e33 chore: cargo fmt in leptos_macro 2024-02-24 07:12:46 -05:00
Greg Johnston
545e87e540 chore(ci): fix failing CI by removing deprecation note 2024-02-24 07:07:31 -05:00
Joseph Cruz
2ca30a0b2d ci(examples): build hackernews_js_fetch with deno (#2344)
* ci(examples): refactor process management

* ci(examples): build hackernews_js_fetch with deno

* ci(workflows): detect hackernews_js_fetch change

* chore(web-report): report deno usage

* chore(web-report): ignore gtk example

* ci(todo_app_sqlite): simulate change

* ci(workflows): install deno

* ci(todo_app_sqlite): remove simulated change
2024-02-23 13:40:44 -05:00
zoomiti
753bf1ed54 Fix Broken Doc links and Deprecate FromUtf8Error in oco.rs (#2318)
* fix: deprecate `FromUtf8Error` in `oco.rs`

* chore: fix broken doc links (#859)

* chore: fix broken doc link to server attribute macro

* cargo fmt
2024-02-21 19:24:40 -08:00
Sam Judelson
37c6387fea finish doc sentence (#2348) 2024-02-21 19:21:57 -08:00
Janu (Janeshwar) Cambrelen
0a73487152 feat(leptos-axum): propagate trace context to server functions (#2340) 2024-02-21 19:21:00 -08:00
Sam Judelson
747aba0d7f add comment specifying edgecase of server function prefixes (#2345) 2024-02-20 18:17:20 -08:00
Sam Judelson
04cf47d5da Update suspense_component.rs documentation to use .get() instead of .read() (#2346) 2024-02-20 18:16:03 -08:00
rjmac
47abe00993 fix: don't leak canceled timeouts (#2331)
Instead of using `Closure::once_into_js`, this uses `into_js_value`,
which uses weak references to clean up the closure when Javascript no
longer has need of it.

It would be nice to make this (and the similar interval function) drop
the callback promptly when cancelled, but I don't think that's
possible while keeping the handles Copy.

Fixes #2330

Co-authored-by: Robert Macomber <robertm@mox>
2024-02-19 21:17:26 -05:00
eliza
aa3700ffb9 feat: add impl_from argument to #[server] proc_macro (#2335)
* Add `impl_into` argument

* Add `impl_into` argument

* Revert unneeded changes

* Address review comments

Rename `impl_into` back to be `impl_from`
Rework docstring

* Fix typo in docstring
2024-02-19 21:16:46 -05:00
Aphek
0770b87cb7 feat: Add owning memos to allow memos that re-use the previous value (#2139) 2024-02-19 21:16:19 -05:00
benwis
330ebdb018 v0.6.6 2024-02-19 13:48:32 -08:00
benwis
c3179d88cf Moved leptos-spin-macro dep to released version 2024-02-19 12:54:43 -08:00
Sahaj
ffcf3c2952 example: fix href path in tailwind_csr example (#2328) 2024-02-17 13:10:07 -05:00
haslersn
001ca5148e fix: handle cross-origin redirects in server function redirect hook (#2329)
In client-side navigation we now handle redirects returned from
server functions by resolving the location against the current
origin as a base. The base is only relevant if the location
doesn't already include an origin. This fixes cross-origin
redirects.

Note: in order to handle redirects in the same way as the browser
would handle them, we need to use the server function's URL
(typically `<origin>/api/something`) as a base. I leave this as
a TODO for a future leptos version, because it probably
requires changing the signature of the `server_fn` redirect hook.

In order to not be affected by a future breaking change, users
should already start making sure that their redirect locations
either include an origin or at least start with a single slash
(e.g. `Location: /foo`).
2024-02-17 13:09:39 -05:00
Greg Johnston
1e000afa78 examples: fix CSS file name in tailwind_axum (#2324) 2024-02-17 12:56:03 -05:00
Greg Johnston
0f7b8841b2 chore(ci): reduce set of tested features to prevent running out of disk space in server_fn (#2320) 2024-02-16 20:26:26 -05:00
Greg Johnston
7dc0441f6c docs: log error on failing to convert form to ServerFn type, in addition to setting action value (#2319) 2024-02-16 17:11:14 -05:00
Joseph Cruz
0a321a1bd7 docs(examples): update docs (#2313)
* docs(examples): fix metadata typo

* docs(examples): update first step about using cargo make
2024-02-16 13:32:01 -05:00
Greg Johnston
88742952f0 fix: Transition in hydrate mode that isn't initially created (closes #2279) (#2314) 2024-02-16 08:16:09 -05:00
martin frances
8a4b972e0b chore: bump config to 0.14 (#2302) 2024-02-15 20:24:12 -05:00
zoomiti
95bd9cc544 feat: use CDN_PKG_PATH at build time to set alternate base URL for JS/WASM bundles (#2281) (#2283) 2024-02-15 20:21:47 -05:00
Marc-Stefan Cassola
23bc892a24 fix: #[server] macro error type detection (#2298)
In most cases when you return `Result<..., ServerFnError<E>>` this worked but when you tried
`Result<..., leptos::ServerFnError<E>>` then it didn't.
2024-02-15 20:20:41 -05:00
Esteban Borai
830fba794e docs: add missing provide_meta_context() in example (#2311)
Otherwise user gets:

```
use_head() is being called without a MetaContext being provided. We'll automatically create and provide one, but if this is being called in a child route it may cause bugs. To be safe, you should provide_meta_context() somewhere in the root of the app.
```
2024-02-15 20:19:07 -05:00
martin frances
98633c8700 chore(ci): update node version for GitHub Actions (#2303) 2024-02-15 20:17:12 -05:00
David Rebbe
b0f5c39711 example: replace yanked version of session_auth_axum crate (#2310) 2024-02-15 20:16:26 -05:00
Greg Johnston
b54aa7f3f5 Merge pull request #2294 from agilarity/add-cargo-make-leptos 2024-02-15 18:52:37 -05:00
Sam Judelson
e33ee7ec99 pub export server is either from leptos_macro or leptos_spin_macro depending on if spin feature is enabled. (#2280)
* leptos spin server macro

* leptos spin

* git chng

* based on the fermyon official git for when that works
2024-02-15 14:37:19 -08:00
Joseph Cruz
cd70b2f52b fix(ci): should exclude cargo-make 2024-02-11 20:40:20 -05:00
Joseph Cruz
c75842ed0c ci(hackernews_islands_axum): build with cargo leptos 2024-02-11 15:40:32 -05:00
Joseph Cruz
4ad228bf47 docs(test-report): add leptos ci warning 2024-02-11 15:40:32 -05:00
Joseph Cruz
bbe7115360 docs(test-report): mention options 2024-02-11 15:40:32 -05:00
Greg Johnston
0658a550b0 fix(examples): align crate name and output name (closes #2206) (#2291) 2024-02-10 15:47:25 -05:00
Joseph Cruz
4222c832b1 fix(ci): empty directory vector error (#2288)
* fix(ci): empty directory vector error

* chore(ci): simulate example change

* chore(ci): remove simulated example change
2024-02-10 10:02:21 -08:00
Greg Johnston
dfddbd6bf9 docs: give a warning when you try to .dispatch() an action immediately (closes #2225) (#2286) 2024-02-09 20:55:10 -05:00
Greg Johnston
8a77691cb5 Merge pull request #2285 from leptos-rs/fix-issues
Fix remaining CI issues
2024-02-09 19:29:01 -05:00
Greg Johnston
1dbe8b2d4b fix: correct feature name for server-fn-macro crate (broken in #2270) 2024-02-09 17:18:44 -05:00
Greg Johnston
fe64f0d332 examples: fix counter_isomorphic (broken in #2244) and fix additional warnings 2024-02-09 17:12:31 -05:00
Joseph Cruz
c00207aa46 fix(test-report) should show all cargo-make leptos configuration (#2282)
* refactor(test-report): extract script
* refactor(test-report): extract functions
* refactor(test-report): split option to tasks
* chore(test-report): highlight examples without tags
* fix(test-report): show all cargo-make leptos configuration
* docs(test-report): update keys
* chore(test-report): include all crates in report
2024-02-09 16:31:46 -05:00
77 changed files with 1782 additions and 348 deletions

View File

@@ -31,10 +31,9 @@ jobs:
dir_names: true
dir_names_max_depth: "2"
files: |
examples
!examples/cargo-make
!examples/gtk
!examples/hackernews_js_fetch
examples/**
!examples/cargo-make/**
!examples/gtk/**
!examples/Makefile.toml
!examples/*.md
json: true

View File

@@ -25,8 +25,8 @@ jobs:
with:
files: |
examples/**
!examples/cargo-make
!examples/gtk
!examples/cargo-make/**
!examples/gtk/**
!examples/Makefile.toml
!examples/*.md

View File

@@ -55,9 +55,9 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
@@ -107,6 +107,11 @@ jobs:
fi
done
- name: Install Deno
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
# Run Cargo Make Task
- name: ${{ inputs.cargo_make_task }}
run: |

View File

@@ -25,7 +25,8 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.6.5"
version = "0.6.8"
rust-version = "1.75"
[workspace.dependencies]
leptos = { path = "./leptos", version = "0.6.5" }

View File

@@ -2,6 +2,7 @@
name = "benchmarks"
version = "0.1.0"
edition = "2021"
rust-version.workspace = true
[dependencies]
l0410 = { package = "leptos", version = "0.4.10", features = [

View File

@@ -51,103 +51,5 @@ echo "CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = $examples"
[tasks.test-report]
workspace = false
description = "report web testing technology used by examples - OPTION: [all]"
script = '''
set -emu
BOLD="\e[1m"
GREEN="\e[0;32m"
ITALIC="\e[3m"
YELLOW="\e[0;33m"
RESET="\e[0m"
echo
echo "${YELLOW}Web Test Technology${RESET}"
echo
makefile_paths=$(find . -name Makefile.toml -not -path '*/target/*' -not -path '*/node_modules/*' |
sed 's%./%%' |
sed 's%/Makefile.toml%%' |
grep -v Makefile.toml |
sort -u)
start_path=$(pwd)
for path in $makefile_paths; do
cd $path
crate_symbols=
pw_count=$(find . -name playwright.config.ts | wc -l)
while read -r line; do
case $line in
*"cucumber"*)
crate_symbols=$crate_symbols"C"
;;
*"fantoccini"*)
crate_symbols=$crate_symbols"D"
;;
esac
done <"./Cargo.toml"
while read -r line; do
case $line in
*"cargo-make/wasm-test.toml"*)
crate_symbols=$crate_symbols"W"
;;
*"cargo-make/playwright-test.toml"*)
crate_symbols=$crate_symbols"P"
crate_symbols=$crate_symbols"N"
;;
*"cargo-make/playwright-trunk-test.toml"*)
crate_symbols=$crate_symbols"P"
crate_symbols=$crate_symbols"T"
;;
*"cargo-make/trunk_server.toml"*)
crate_symbols=$crate_symbols"T"
;;
*"cargo-make/cargo-leptos-webdriver-test.toml"*)
crate_symbols=$crate_symbols"L"
;;
*"cargo-make/cargo-leptos-test.toml"*)
crate_symbols=$crate_symbols"L"
if [ $pw_count -gt 0 ]; then
crate_symbols=$crate_symbols"P"
fi
;;
esac
done <"./Makefile.toml"
# Sort list of tools
sorted_crate_symbols=$(echo ${crate_symbols} | grep -o . | sort | tr -d "\n")
formatted_crate_symbols="${BOLD}${YELLOW}${sorted_crate_symbols}${RESET}"
crate_line=$path
if [ ! -z ${1+x} ]; then
# Show all examples
if [ ! -z $crate_symbols ]; then
crate_line=$crate_line$formatted_crate_symbols
fi
echo $crate_line
elif [ ! -z $crate_symbols ]; then
# Filter out examples that do not run tests in `ci`
crate_line=$crate_line$formatted_crate_symbols
echo $crate_line
fi
cd ${start_path}
done
c="${BOLD}${YELLOW}C${RESET} = Cucumber"
d="${BOLD}${YELLOW}D${RESET} = WebDriver"
l="${BOLD}${YELLOW}L${RESET} = Cargo Leptos"
n="${BOLD}${YELLOW}N${RESET} = Node"
p="${BOLD}${YELLOW}P${RESET} = Playwright"
t="${BOLD}${YELLOW}T${RESET} = Trunk"
w="${BOLD}${YELLOW}W${RESET} = WASM"
echo
echo "${ITALIC}Keys:${RESET} $c, $d, $l, $n, $p, $t, $w"
echo
'''
description = "show the cargo-make configuration for web examples [web|all|help]"
script = { file = "./cargo-make/scripts/web-report.sh" }

View File

@@ -16,7 +16,7 @@ You can also run any of the examples using [`cargo-make`](https://github.com/sag
Follow these steps to get any example up and running.
1. `cd` to the example root directory
1. `cd` to the example you want to run
2. Run `cargo make ci` to setup and test the example
3. Run `cargo make start` to run the example
4. Open the client URL in the console output (<http://127.0.0.1:8080> or <http://127.0.0.1:3000> by default)

View File

@@ -3,32 +3,36 @@
[tasks.stop-client]
condition = { env_set = ["CLIENT_PROCESS_NAME"] }
script = '''
if [ ! -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
if pidof -q ${CLIENT_PROCESS_NAME}; then
echo " Stopping ${CLIENT_PROCESS_NAME}"
pkill -ef ${CLIENT_PROCESS_NAME}
else
echo " ${CLIENT_PROCESS_NAME} is already stopped"
fi
'''
[tasks.client-status]
condition = { env_set = ["CLIENT_PROCESS_NAME"] }
script = '''
if [ -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
echo " ${CLIENT_PROCESS_NAME} is not running"
else
if pidof -q ${CLIENT_PROCESS_NAME}; then
echo " ${CLIENT_PROCESS_NAME} is up"
else
echo " ${CLIENT_PROCESS_NAME} is not running"
fi
'''
[tasks.maybe-start-client]
condition = { env_set = ["CLIENT_PROCESS_NAME"] }
script = '''
if [ -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
if pidof -q ${CLIENT_PROCESS_NAME}; then
echo " ${CLIENT_PROCESS_NAME} is already started"
else
echo " Starting ${CLIENT_PROCESS_NAME}"
if [ -z ${SPAWN_CLIENT_PROCESS} ];then
if [ -n "${SPAWN_CLIENT_PROCESS}" ];then
echo "Spawning process..."
cargo make start-client ${@} &
else
cargo make start-client ${@}
fi
else
echo " ${CLIENT_PROCESS_NAME} is already started"
fi
'''

View File

@@ -0,0 +1,24 @@
[tasks.build]
clear = true
command = "deno"
args = ["task", "build"]
[tasks.start-client]
command = "deno"
args = ["task", "start"]
[tasks.check]
clear = true
dependencies = ["check-debug", "check-release"]
[tasks.check-debug]
toolchain = "nightly-2024-01-29"
command = "cargo"
args = ["check-all-features"]
install_crate = "cargo-all-features"
[tasks.check-release]
toolchain = "nightly-2024-01-29"
command = "cargo"
args = ["check-all-features", "--release"]
install_crate = "cargo-all-features"

View File

@@ -6,9 +6,17 @@ extend = [
[tasks.integration-test]
description = "Run integration test with automated start and stop of processes"
env = { SPAWN_CLIENT_PROCESS = "1" }
dependencies = ["start", "wait-one", "test-playwright", "stop"]
run_task = { name = ["start", "wait-test-stop"], parallel = true }
[tasks.wait-one]
[tasks.wait-test-stop]
private = true
dependencies = ["wait-server", "test-playwright", "stop"]
[tasks.wait-server]
script = '''
sleep 1
for run in {1..12}; do
echo "Waiting to ensure server is started..."
sleep 10
done
echo "Times up, running tests"
'''

View File

@@ -0,0 +1,176 @@
#!/bin/bash
set -emu
BOLD="\e[1m"
ITALIC="\e[3m"
YELLOW="\e[1;33m"
BLUE="\e[1;36m"
RESET="\e[0m"
function web { #task: only include examples with web cargo-make configuration
print_header
print_crate_tags "$@"
print_footer
}
function all { #task: includes all examples
print_header
print_crate_tags "all"
print_footer
}
function print_header {
echo -e "${YELLOW}Cargo Make Web Report${RESET}"
echo
echo -e "${ITALIC}Show how crates are configured to run and test web examples with cargo-make${RESET}"
echo
}
function print_crate_tags {
local makefile_paths
makefile_paths=$(find_makefile_lines)
local start_path
start_path=$(pwd)
for path in $makefile_paths; do
cd "$path"
local crate_tags=
# Add cargo tags
while read -r line; do
case $line in
*"cucumber"*)
crate_tags=$crate_tags"C"
;;
*"fantoccini"*)
crate_tags=$crate_tags"F"
;;
*"package.metadata.leptos"*)
crate_tags=$crate_tags"M"
;;
esac
done <"./Cargo.toml"
#Add makefile tags
local pw_count
pw_count=$(find . -name playwright.config.ts | wc -l)
while read -r line; do
case $line in
*"cargo-make/wasm-test.toml"*)
crate_tags=$crate_tags"W"
;;
*"cargo-make/playwright-test.toml"*)
crate_tags=$crate_tags"P"
crate_tags=$crate_tags"N"
;;
*"cargo-make/playwright-trunk-test.toml"*)
crate_tags=$crate_tags"P"
crate_tags=$crate_tags"T"
;;
*"cargo-make/trunk_server.toml"*)
crate_tags=$crate_tags"T"
;;
*"cargo-make/cargo-leptos-webdriver-test.toml"*)
crate_tags=$crate_tags"L"
;;
*"cargo-make/cargo-leptos-test.toml"*)
crate_tags=$crate_tags"L"
if [ "$pw_count" -gt 0 ]; then
crate_tags=$crate_tags"P"
fi
;;
*"cargo-make/cargo-leptos.toml"*)
crate_tags=$crate_tags"L"
;;
*"cargo-make/deno-build.toml"*)
crate_tags=$crate_tags"D"
;;
esac
done <"./Makefile.toml"
# Sort tags
local keys
keys=$(echo "$crate_tags" | grep -o . | sort | tr -d "\n")
# Find leptos projects that are not configured to build with cargo-leptos
keys=${keys//"LM"/"L"}
# Find leptos projects that are not configured to build with deno
keys=${keys//"DM"/"D"}
# Maybe print line
local crate_line=$path
if [ -n "$crate_tags" ]; then
local color=$YELLOW
case $keys in
*"M"*)
color=$BLUE
;;
esac
crate_line="$crate_line${color}$keys${RESET}"
echo -e "$crate_line"
elif [ "$#" -gt 0 ]; then
crate_line="${BOLD}$crate_line${RESET}"
echo -e "$crate_line"
fi
cd "$start_path"
done
}
function find_makefile_lines {
find . -name Makefile.toml -not -path '*/target/*' -not -path '*/node_modules/*' |
sed 's%./%%' |
sed 's%/Makefile.toml%%' |
grep -v Makefile.toml |
sort -u
}
function print_footer {
c="${BOLD}${YELLOW}C${RESET} = Cucumber Test Runner"
d="${BOLD}${YELLOW}D${RESET} = Deno"
f="${BOLD}${YELLOW}F${RESET} = Fantoccini WebDriver"
l="${BOLD}${YELLOW}L${RESET} = Cargo Leptos"
m="${BOLD}${BLUE}M${RESET} = Cargo Leptos Metadata Only (${ITALIC}ci is not configured to build with cargo-leptos or deno${RESET})"
n="${BOLD}${YELLOW}N${RESET} = Node"
p="${BOLD}${YELLOW}P${RESET} = Playwright Test"
t="${BOLD}${YELLOW}T${RESET} = Trunk"
w="${BOLD}${YELLOW}W${RESET} = WASM Test"
echo
echo -e "${ITALIC}Report Keys:${RESET}\n $c\n $d\n $f\n $l\n $m\n $n\n $p\n $t\n $w"
echo
}
###################
# HELP
###################
function list_help_for {
local task=$1
grep -E "^function.+ #$task" "$0" |
sed 's/function/ /' |
sed -e "s| { #$task: |~|g" |
column -s"~" -t |
sort
}
function help { #help: show task descriptions
echo -e "${BOLD}Usage:${RESET} ./$(basename "$0") <task> [options]"
echo
echo "Show the cargo-make configuration for web examples"
echo
echo -e "${BOLD}Tasks:${RESET}"
list_help_for task
echo
}
TIMEFORMAT="./web-report.sh completed in %3lR"
time "${@:-all}" # Show the report by default

View File

@@ -3,18 +3,21 @@
[tasks.stop-server]
condition = { env_set = ["SERVER_PROCESS_NAME"] }
script = '''
if [ ! -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
if pidof -q ${SERVER_PROCESS_NAME}; then
echo " Stopping ${SERVER_PROCESS_NAME}"
pkill -ef ${SERVER_PROCESS_NAME}
else
echo " ${SERVER_PROCESS_NAME} is already stopped"
fi
'''
[tasks.server-status]
condition = { env_set = ["SERVER_PROCESS_NAME"] }
script = '''
if [ -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
echo " ${SERVER_PROCESS_NAME} is not running"
else
if pidof -q ${SERVER_PROCESS_NAME}; then
echo " ${SERVER_PROCESS_NAME} is up"
else
echo " ${SERVER_PROCESS_NAME} is not running"
fi
'''
@@ -24,11 +27,11 @@ script = '''
YELLOW="\e[0;33m"
RESET="\e[0m"
if [ -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
if pidof -q ${SERVER_PROCESS_NAME}; then
echo " ${SERVER_PROCESS_NAME} is already started"
else
echo " Starting ${SERVER_PROCESS_NAME}"
echo " ${YELLOW}>> Run cargo make stop to end process${RESET}"
cargo make start-server ${@} &
else
echo " ${SERVER_PROCESS_NAME} is already started"
fi
'''

View File

@@ -6,25 +6,33 @@ script = '''
RESET="\e[0m"
if command -v chromedriver; then
if [ -z $(pidof chromedriver) ]; then
if pidof -q chromedriver; then
echo " chromedriver is already started"
else
echo "Starting chomedriver"
chromedriver --port=4444 &
fi
else
echo "${RED}${BOLD}ERROR${RESET} - chromedriver is required by this task"
echo "${RED}${BOLD}ERROR${RESET} - chromedriver not found"
exit 1
fi
'''
[tasks.stop-webdriver]
script = '''
pkill -f "chromedriver"
if pidof -q chromedriver; then
echo " Stopping chromedriver"
pkill -ef "chromedriver"
else
echo " chromedriver is already stopped"
fi
'''
[tasks.webdriver-status]
script = '''
if [ -z $(pidof chromedriver) ]; then
echo chromedriver is not running
else
if pidof -q chromedriver; then
echo chromedriver is up
else
echo chromedriver is not running
fi
'''

View File

@@ -141,9 +141,12 @@ pub fn Counter() -> impl IntoView {
<div>
<button on:click=move |_| clear.dispatch(())>"Clear"</button>
<button on:click=move |_| dec.dispatch(())>"-1"</button>
<Suspense fallback=move |_| view!{ <span>"Value: "</span>}>
<span>"Value: " { counter.get().map(|count| count.unwrap_or(0)).unwrap_or(0);} "!"</span>
</Suspense>
<span>
"Value: "
<Suspense>
{move || counter.and_then(|count| *count)} "!"
</Suspense>
</span>
<button on:click=move |_| inc.dispatch(())>"+1"</button>
</div>
<Suspense>
@@ -201,7 +204,7 @@ pub fn FormCounter() -> impl IntoView {
<input type="hidden" name="msg" value="form value down"/>
<input type="submit" value="-1"/>
</ActionForm>
<span>"Value: " {move || value().to_string()} "!"</span>
<span>"Value: " <Suspense>{move || value().to_string()} "!"</Suspense></span>
<ActionForm action=adjust>
<input type="hidden" name="delta" value="1"/>
<input type="hidden" name="msg" value="form value up"/>

View File

@@ -2,6 +2,7 @@
name = "counter_without_macros"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
[profile.release]
codegen-units = 1

View File

@@ -2,6 +2,7 @@
name = "counters_stable"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }

View File

@@ -1,8 +0,0 @@
[env]
VERIFY_GTK = false
[tasks.verify-flow]
condition = { env_set = ["VERIFY_GTK"] }
[tasks.verify]
condition = { env_set = ["VERIFY_GTK"] }

View File

@@ -1 +1,8 @@
extend = [{ path = "../cargo-make/main.toml" }]
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
[env]
CLIENT_PROCESS_NAME = "hackernews_islands"

View File

@@ -1 +1,8 @@
extend = [{ path = "../cargo-make/main.toml" }]
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/deno-build.toml" },
]
[env]
CLIENT_PROCESS_NAME = "deno"

View File

@@ -30,10 +30,10 @@ sqlx = { version = "0.7.2", features = [
], optional = true }
thiserror = "1.0"
wasm-bindgen = "0.2"
axum_session_auth = { version = "0.10", features = [
axum_session_auth = { version = "0.12.1", features = [
"sqlite-rustls",
], optional = true }
axum_session = { version = "0.10", features = [
axum_session = { version = "0.12.4", features = [
"sqlite-rustls",
], optional = true }
bcrypt = { version = "0.15", optional = true }

View File

@@ -70,7 +70,7 @@ async fn main() {
SessionConfig::default().with_table_name("axum_sessions");
let auth_config = AuthConfig::<i64>::default();
let session_store = SessionStore::<SessionSqlitePool>::new(
Some(pool.clone().into()),
Some(SessionSqlitePool::from(pool.clone())),
session_config,
)
.await

View File

@@ -9,12 +9,13 @@ use thiserror::Error;
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
let fallback = || view! { "Page not found." }.into_view();
view! {
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
<Title text="Welcome to Leptos"/>
<Router>
<Router fallback>
<main>
<Routes>
// Well load the home page with out-of-order streaming and <Suspense/>

View File

@@ -9,12 +9,13 @@ use thiserror::Error;
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
let fallback = || view! { "Page not found." }.into_view();
view! {
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
<Title text="Welcome to Leptos"/>
<Router>
<Router fallback>
<main>
<Routes>
// Well load the home page with out-of-order streaming and <Suspense/>

View File

@@ -44,7 +44,7 @@ skip_feature_sets = [["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "tailwind_axum"
output-name = "leptos_tailwind"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written

View File

@@ -9,7 +9,6 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"preline": "^1.8.0",
"tailwindcss": "^3.3.2"
}
},
@@ -104,15 +103,6 @@
"node": ">= 8"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -699,14 +689,6 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/preline": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/preline/-/preline-1.8.0.tgz",
"integrity": "sha512-guttn86Fc/+AbvN9oKcr2z3zU7DL3Q5dl7nhcR4nTi5F02LXQc7WIYwgIXMR97kymCs52feiju6glXO3dUIpvA==",
"dependencies": {
"@popperjs/core": "^2.11.2"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -7,8 +7,7 @@ pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/tailwind_axum.css"/>
<Stylesheet id="leptos" href="/pkg/leptos_tailwind.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Router>
<Routes>

View File

@@ -8,7 +8,7 @@ pub fn App() -> impl IntoView {
view! {
<Stylesheet id="leptos" href="/pkg/tailwind.css"/>
<Stylesheet id="leptos" href="/style/output.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Router>
<Routes>

View File

@@ -6,6 +6,7 @@ authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Actix integrations for the Leptos web framework."
rust-version.workspace = true
[dependencies]
actix-http = "3"

View File

@@ -203,7 +203,7 @@ pub fn handle_server_fns() -> Route {
/// context, allowing you to pass in info about the route or user from Actix, or other info.
///
/// **NOTE**: If your server functions expect a context, make sure to provide it both in
/// [`handle_server_fns_with_context`] **and** in [`leptos_routes_with_context`] (or whatever
/// [`handle_server_fns_with_context`] **and** in [`LeptosRoutes::leptos_routes_with_context`] (or whatever
/// rendering method you are using). During SSR, server functions are called by the rendering
/// method, while subsequent calls from the client are handled by the server function handler.
/// The same context needs to be provided to both handlers.
@@ -1215,13 +1215,18 @@ where
let mode = listing.mode();
for method in listing.methods() {
let additional_context = additional_context.clone();
let additional_context_and_method = move || {
provide_context(method);
additional_context();
};
router = if let Some(static_mode) = listing.static_mode() {
router.route(
path,
static_route(
options.clone(),
app_fn.clone(),
additional_context.clone(),
additional_context_and_method.clone(),
method,
static_mode,
),
@@ -1233,7 +1238,7 @@ where
SsrMode::OutOfOrder => {
render_app_to_stream_with_context(
options.clone(),
additional_context.clone(),
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
@@ -1241,7 +1246,7 @@ where
SsrMode::PartiallyBlocked => {
render_app_to_stream_with_context_and_replace_blocks(
options.clone(),
additional_context.clone(),
additional_context_and_method.clone(),
app_fn.clone(),
method,
true,
@@ -1250,14 +1255,14 @@ where
SsrMode::InOrder => {
render_app_to_stream_in_order_with_context(
options.clone(),
additional_context.clone(),
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
}
SsrMode::Async => render_app_async_with_context(
options.clone(),
additional_context.clone(),
additional_context_and_method.clone(),
app_fn.clone(),
method,
),

View File

@@ -0,0 +1,148 @@
use leptos::*;
use leptos_actix::generate_route_list;
use leptos_router::{Route, Router, Routes, TrailingSlash};
#[component]
fn DefaultApp() -> impl IntoView {
let view = || view! { "" };
view! {
<Router>
<Routes>
<Route path="/foo" view/>
<Route path="/bar/" view/>
<Route path="/baz/:id" view/>
<Route path="/baz/:name/" view/>
<Route path="/baz/*any" view/>
</Routes>
</Router>
}
}
#[test]
fn test_default_app() {
let routes = generate_route_list(DefaultApp);
// We still have access to the original (albeit normalized) Leptos paths:
assert_same(
&routes,
|r| r.leptos_path(),
&["/bar", "/baz/*any", "/baz/:id", "/baz/:name", "/foo"],
);
// ... But leptos-actix has also reformatted "paths" to work for Actix.
assert_same(
&routes,
|r| r.path(),
&["/bar", "/baz/{id}", "/baz/{name}", "/baz/{tail:.*}", "/foo"],
);
}
#[component]
fn ExactApp() -> impl IntoView {
let view = || view! { "" };
let trailing_slash = TrailingSlash::Exact;
view! {
<Router trailing_slash>
<Routes>
<Route path="/foo" view/>
<Route path="/bar/" view/>
<Route path="/baz/:id" view/>
<Route path="/baz/:name/" view/>
<Route path="/baz/*any" view/>
</Routes>
</Router>
}
}
#[test]
fn test_exact_app() {
let routes = generate_route_list(ExactApp);
// In Exact mode, the Leptos paths no longer have their trailing slashes stripped:
assert_same(
&routes,
|r| r.leptos_path(),
&["/bar/", "/baz/*any", "/baz/:id", "/baz/:name/", "/foo"],
);
// Actix paths also have trailing slashes as a result:
assert_same(
&routes,
|r| r.path(),
&[
"/bar/",
"/baz/{id}",
"/baz/{name}/",
"/baz/{tail:.*}",
"/foo",
],
);
}
#[component]
fn RedirectApp() -> impl IntoView {
let view = || view! { "" };
let trailing_slash = TrailingSlash::Redirect;
view! {
<Router trailing_slash>
<Routes>
<Route path="/foo" view/>
<Route path="/bar/" view/>
<Route path="/baz/:id" view/>
<Route path="/baz/:name/" view/>
<Route path="/baz/*any" view/>
</Routes>
</Router>
}
}
#[test]
fn test_redirect_app() {
let routes = generate_route_list(RedirectApp);
assert_same(
&routes,
|r| r.leptos_path(),
&[
"/bar",
"/bar/",
"/baz/*any",
"/baz/:id",
"/baz/:id/",
"/baz/:name",
"/baz/:name/",
"/foo",
"/foo/",
],
);
// ... But leptos-actix has also reformatted "paths" to work for Actix.
assert_same(
&routes,
|r| r.path(),
&[
"/bar",
"/bar/",
"/baz/{id}",
"/baz/{id}/",
"/baz/{name}",
"/baz/{name}/",
"/baz/{tail:.*}",
"/foo",
"/foo/",
],
);
}
fn assert_same<'t, T, F, U>(
input: &'t Vec<T>,
mapper: F,
expected_sorted_values: &[U],
) where
F: Fn(&'t T) -> U + 't,
U: Ord + std::fmt::Debug,
{
let mut values: Vec<U> = input.iter().map(mapper).collect();
values.sort();
assert_eq!(values, expected_sorted_values);
}

View File

@@ -6,6 +6,7 @@ authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Axum integrations for the Leptos web framework."
rust-version.workspace = true
[dependencies]
axum = { version = "0.7", default-features = false, features = [

View File

@@ -79,6 +79,20 @@ impl ResponseParts {
}
/// Allows you to override details of the HTTP response like the status code and add Headers/Cookies.
///
/// `ResponseOptions` is provided via context when you use most of the handlers provided in this
/// crate, including [`.leptos_routes`](LeptosRoutes::leptos_routes),
/// [`.leptos_routes_with_context`](LeptosRoutes::leptos_routes_with_context), [`handle_server_fns`], etc.
/// You can find the full set of provided context types in each handler function.
///
/// If you provide your own handler, you will need to provide `ResponseOptions` via context
/// yourself if you want to access it via context.
/// ```rust,ignore
/// #[server]
/// pub async fn get_opts() -> Result<(), ServerFnError> {
/// let opts = expect_context::<leptos_axum::ResponseOptions>();
/// Ok(())
/// }
#[derive(Debug, Clone, Default)]
pub struct ResponseOptions(pub Arc<RwLock<ResponseParts>>);
@@ -257,7 +271,13 @@ async fn handle_server_fns_inner(
let (tx, rx) = futures::channel::oneshot::channel();
// capture current span to enable trace context propagation
let current_span = tracing::Span::current();
spawn_task!(async move {
// enter captured span for trace context propagation in spawned task
let _guard = current_span.enter();
let path = req.uri().path().to_string();
let (req, parts) = generate_request_and_parts(req);
@@ -1609,6 +1629,11 @@ where
let path = listing.path();
for method in listing.methods() {
let cx_with_state = cx_with_state.clone();
let cx_with_state_and_method = move || {
provide_context(method);
cx_with_state();
};
router = if let Some(static_mode) = listing.static_mode() {
#[cfg(feature = "default")]
{
@@ -1617,7 +1642,7 @@ where
path,
LeptosOptions::from_ref(options),
app_fn.clone(),
cx_with_state.clone(),
cx_with_state_and_method.clone(),
method,
static_mode,
)
@@ -1637,7 +1662,7 @@ where
SsrMode::OutOfOrder => {
let s = render_app_to_stream_with_context(
LeptosOptions::from_ref(options),
cx_with_state.clone(),
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
@@ -1651,7 +1676,7 @@ where
SsrMode::PartiallyBlocked => {
let s = render_app_to_stream_with_context_and_replace_blocks(
LeptosOptions::from_ref(options),
cx_with_state.clone(),
cx_with_state_and_method.clone(),
app_fn.clone(),
true
);
@@ -1666,7 +1691,7 @@ where
SsrMode::InOrder => {
let s = render_app_to_stream_in_order_with_context(
LeptosOptions::from_ref(options),
cx_with_state.clone(),
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
@@ -1680,7 +1705,7 @@ where
SsrMode::Async => {
let s = render_app_async_with_context(
LeptosOptions::from_ref(options),
cx_with_state.clone(),
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {

View File

@@ -6,6 +6,7 @@ authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Utilities to help build server integrations for the Leptos web framework."
rust-version.workspace = true
[dependencies]
futures = "0.3"

View File

@@ -2,6 +2,7 @@ use futures::{Stream, StreamExt};
use leptos::{nonce::use_nonce, use_context, RuntimeId};
use leptos_config::LeptosOptions;
use leptos_meta::MetaContext;
use std::{borrow::Cow, collections::HashMap, env, fs};
extern crate tracing;
@@ -55,7 +56,9 @@ pub fn html_parts_separated(
options: &LeptosOptions,
meta: Option<&MetaContext>,
) -> (String, &'static str) {
let pkg_path = &options.site_pkg_dir;
let pkg_path = option_env!("CDN_PKG_PATH")
.map(Cow::from)
.unwrap_or_else(|| format!("/{}", options.site_pkg_dir).into());
let output_name = &options.output_name;
let nonce = use_nonce();
let nonce = nonce
@@ -100,6 +103,14 @@ pub fn html_parts_separated(
} else {
"() => mod.hydrate()"
};
let (js_hash, wasm_hash, css_hash) = get_hashes(options);
let head = head.replace(
&format!("{output_name}.css"),
&format!("{output_name}{css_hash}.css"),
);
let head = format!(
r#"<!DOCTYPE html>
<html{html_metadata}>
@@ -107,8 +118,8 @@ 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"{nonce}>
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin=""{nonce}>
<link rel="modulepreload" href="{pkg_path}/{output_name}{js_hash}.js"{nonce}>
<link rel="preload" href="{pkg_path}/{wasm_output_name}{wasm_hash}.wasm" as="fetch" type="application/wasm" crossorigin=""{nonce}>
<script type="module"{nonce}>
function idle(c) {{
if ("requestIdleCallback" in window) {{
@@ -118,9 +129,9 @@ pub fn html_parts_separated(
}}
}}
idle(() => {{
import('/{pkg_path}/{output_name}.js')
import('{pkg_path}/{output_name}{js_hash}.js')
.then(mod => {{
mod.default('/{pkg_path}/{wasm_output_name}.wasm').then({import_callback});
mod.default('{pkg_path}/{wasm_output_name}{wasm_hash}.wasm').then({import_callback});
}})
}});
</script>
@@ -131,6 +142,46 @@ pub fn html_parts_separated(
(head, tail)
}
#[tracing::instrument(level = "trace", fields(error), skip_all)]
fn get_hashes(options: &LeptosOptions) -> (String, String, String) {
let mut ext_to_hash = HashMap::from([
("js".to_string(), "".to_string()),
("wasm".to_string(), "".to_string()),
("css".to_string(), "".to_string()),
]);
if options.hash_files {
let hash_path = env::current_exe()
.map(|path| {
path.parent().map(|p| p.to_path_buf()).unwrap_or_default()
})
.unwrap_or_default()
.join(&options.hash_file);
if hash_path.exists() {
let hashes = fs::read_to_string(&hash_path)
.expect("failed to read hash file");
for line in hashes.lines() {
let line = line.trim();
if !line.is_empty() {
if let Some((k, v)) = line.split_once(':') {
ext_to_hash.insert(
k.trim().to_string(),
format!(".{}", v.trim()),
);
}
}
}
}
}
(
ext_to_hash["js"].clone(),
ext_to_hash["wasm"].clone(),
ext_to_hash["css"].clone(),
)
}
#[tracing::instrument(level = "trace", fields(error), skip_all)]
pub async fn build_async_response(
stream: impl Stream<Item = String> + 'static,

View File

@@ -7,6 +7,7 @@ license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Leptos is a full-stack, isomorphic Rust web framework leveraging fine-grained reactivity to build declarative user interfaces."
readme = "../README.md"
rust-version.workspace = true
[dependencies]
cfg-if = "1"
@@ -15,6 +16,7 @@ leptos_macro = { workspace = true }
leptos_reactive = { workspace = true }
leptos_server = { workspace = true }
leptos_config = { workspace = true }
leptos-spin-macro = { version="0.1", optional = true}
tracing = "0.1"
typed-builder = "0.18"
typed-builder-macro = "0.18"
@@ -66,7 +68,10 @@ miniserde = ["leptos_reactive/miniserde"]
rkyv = ["leptos_reactive/rkyv"]
tracing = ["leptos_macro/tracing"]
nonce = ["leptos_dom/nonce"]
spin = ["leptos_reactive/spin"]
spin = [
"leptos_reactive/spin",
"leptos-spin-macro"
]
experimental-islands = [
"leptos_dom/experimental-islands",
"leptos_macro/experimental-islands",
@@ -87,7 +92,9 @@ denylist = [
"rustls",
"default-tls",
"wasm-bindgen",
"trace-component-props"
"trace-component-props",
"spin",
"experimental-islands"
]
skip_feature_sets = [
[

View File

@@ -179,7 +179,14 @@ pub mod error {
pub use leptos_macro::template;
#[cfg(not(all(target_arch = "wasm32", feature = "template_macro")))]
pub use leptos_macro::view as template;
pub use leptos_macro::{component, island, server, slice, slot, view, Params};
pub use leptos_macro::{component, island, slice, slot, view, Params};
cfg_if::cfg_if!(
if #[cfg(feature="spin")] {
pub use leptos_spin_macro::server;
} else {
pub use leptos_macro::server;
}
);
pub use leptos_reactive::*;
pub use leptos_server::{
self, create_action, create_multi_action, create_server_action,

View File

@@ -36,7 +36,7 @@ use std::rc::Rc;
/// <div>
/// <Suspense fallback=move || view! { <p>"Loading (Suspense Fallback)..."</p> }>
/// {move || {
/// cats.read().map(|data| match data {
/// cats.get().map(|data| match data {
/// None => view! { <pre>"Error"</pre> }.into_view(),
/// Some(cats) => cats
/// .iter()
@@ -173,6 +173,9 @@ where
runtime,
);
#[cfg(feature = "experimental-islands")]
let prev_no_hydrate =
SharedContext::no_hydrate();
#[cfg(feature = "experimental-islands")]
{
SharedContext::set_no_hydrate(
@@ -180,7 +183,7 @@ where
);
}
with_owner(owner, {
let rendered = with_owner(owner, {
move || {
HydrationCtx::continue_from(
current_id,
@@ -194,7 +197,15 @@ where
.render_to_string()
.to_string()
}
})
});
#[cfg(feature = "experimental-islands")]
SharedContext::set_no_hydrate(
prev_no_hydrate,
);
#[allow(clippy::let_and_return)]
rendered
}
},
// in-order streaming
@@ -205,6 +216,9 @@ where
runtime,
);
#[cfg(feature = "experimental-islands")]
let prev_no_hydrate =
SharedContext::no_hydrate();
#[cfg(feature = "experimental-islands")]
{
SharedContext::set_no_hydrate(
@@ -212,7 +226,7 @@ where
);
}
with_owner(owner, {
let rendered = with_owner(owner, {
move || {
HydrationCtx::continue_from(
current_id,
@@ -225,7 +239,15 @@ where
.into_view()
.into_stream_chunks()
}
})
});
#[cfg(feature = "experimental-islands")]
SharedContext::set_no_hydrate(
prev_no_hydrate,
);
#[allow(clippy::let_and_return)]
rendered
}
},
);

View File

@@ -155,7 +155,9 @@ fn is_first_run(
first_run: RwSignal<bool>,
suspense_context: &SuspenseContext,
) -> bool {
if cfg!(feature = "csr") {
if cfg!(feature = "csr")
|| (cfg!(feature = "hydrate") && !HydrationCtx::is_hydrating())
{
false
} else {
match (

View File

@@ -7,9 +7,10 @@ license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Configuration for the Leptos web framework."
readme = "../README.md"
rust-version.workspace = true
[dependencies]
config = { version = "0.13.3", default-features = false, features = ["toml"] }
config = { version = "0.14", default-features = false, features = ["toml"] }
regex = "1.7.0"
serde = { version = "1.0.151", features = ["derive"] }
thiserror = "1.0.38"

View File

@@ -1,4 +1,4 @@
use std::{net::AddrParseError, num::ParseIntError};
use std::{net::AddrParseError, num::ParseIntError, str::ParseBoolError};
use thiserror::Error;
#[derive(Debug, Error, Clone)]
@@ -31,3 +31,9 @@ impl From<AddrParseError> for LeptosConfigError {
Self::ConfigError(e.to_string())
}
}
impl From<ParseBoolError> for LeptosConfigError {
fn from(e: ParseBoolError) -> Self {
Self::ConfigError(e.to_string())
}
}

View File

@@ -70,6 +70,15 @@ pub struct LeptosOptions {
#[builder(default = default_not_found_path())]
#[serde(default = "default_not_found_path")]
pub not_found_path: String,
/// The file name of the hash text file generated by cargo-leptos. Defaults to `hash.txt`.
#[builder(default = default_hash_file_name())]
#[serde(default = "default_hash_file_name")]
pub hash_file: String,
/// If true, hashes will be generated for all files in the site_root and added to their file names.
/// Defaults to `true`.
#[builder(default = default_hash_files())]
#[serde(default = "default_hash_files")]
pub hash_files: bool,
}
impl LeptosOptions {
@@ -108,6 +117,8 @@ impl LeptosOptions {
env_w_default("LEPTOS_RELOAD_WS_PROTOCOL", "ws")?.as_str(),
)?,
not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?,
hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?,
hash_files: env_w_default("LEPTOS_HASH_FILES", "false")?.parse()?,
})
}
}
@@ -146,6 +157,14 @@ fn default_not_found_path() -> String {
"/404".to_string()
}
fn default_hash_file_name() -> String {
"hash.txt".to_string()
}
fn default_hash_files() -> bool {
false
}
fn env_wo_default(key: &str) -> Result<Option<String>, LeptosConfigError> {
match std::env::var(key) {
Ok(val) => Ok(Some(val)),

View File

@@ -6,6 +6,7 @@ authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "DOM operations for the Leptos web framework."
rust-version.workspace = true
[dependencies]
async-recursion = "1"

View File

@@ -103,6 +103,25 @@ pub fn request_animation_frame(cb: impl FnOnce() + 'static) {
_ = request_animation_frame_with_handle(cb);
}
// Closure::once_into_js only frees the callback when it's actually
// called, so this instead uses into_js_value, which can be freed by
// the host JS engine's GC if it supports weak references (which all
// modern brower engines do). The way this works is that the provided
// callback's captured data is dropped immediately after being called,
// as before, but it leaves behind a small stub closure rust-side that
// will be freed "eventually" by the JS GC. If the function is never
// called (e.g., it's a cancelled timeout or animation frame callback)
// then it will also be freed eventually.
fn closure_once(cb: impl FnOnce() + 'static) -> JsValue {
let mut wrapped_cb: Option<Box<dyn FnOnce()>> = Some(Box::new(cb));
let closure = Closure::new(move || {
if let Some(cb) = wrapped_cb.take() {
cb()
}
});
closure.into_js_value()
}
/// Runs the given function between the next repaint using
/// [`Window.requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame),
/// returning a cancelable handle.
@@ -128,7 +147,7 @@ pub fn request_animation_frame_with_handle(
.map(AnimationFrameRequestHandle)
}
raf(Closure::once_into_js(cb))
raf(closure_once(cb))
}
/// Handle that is generated by [request_idle_callback_with_handle] and can be
@@ -237,7 +256,7 @@ pub fn set_timeout_with_handle(
.map(TimeoutHandle)
}
st(Closure::once_into_js(cb), duration)
st(closure_once(cb), duration)
}
/// "Debounce" a callback function. This will cause it to wait for a period of `delay`

View File

@@ -7,6 +7,7 @@ license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Utility types used for dev mode and hot-reloading for the Leptos web framework."
readme = "../README.md"
rust-version.workspace = true
[dependencies]
anyhow = "1"

View File

@@ -7,6 +7,7 @@ license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "view macro for the Leptos web framework."
readme = "../README.md"
rust-version.workspace = true
[lib]
proc-macro = true

View File

@@ -447,7 +447,7 @@ impl ToTokens for Model {
};
quote! {
#[::leptos::wasm_bindgen::prelude::wasm_bindgen]
#[::leptos::wasm_bindgen::prelude::wasm_bindgen(wasm_bindgen = ::leptos::wasm_bindgen)]
#[allow(non_snake_case)]
pub fn #hydrate_fn_name(el: ::leptos::web_sys::HtmlElement) {
if let Some(Ok(key)) = el.dataset().get(::leptos::wasm_bindgen::intern("hkc")).map(|key| std::str::FromStr::from_str(&key)) {

View File

@@ -870,6 +870,7 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// - `name`: sets the identifier for the server functions type, which is a struct created
/// to hold the arguments (defaults to the function identifier in PascalCase)
/// - `prefix`: a prefix at which the server function handler will be mounted (defaults to `/api`)
/// your prefix must begin with `/`. Otherwise your function won't be found.
/// - `endpoint`: specifies the exact path at which the server function handler will be mounted,
/// relative to the prefix (defaults to the function name followed by unique hash)
/// - `input`: the encoding for the arguments (defaults to `PostUrl`)
@@ -883,6 +884,11 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// - `"GetCbor"`: `GET` request with URL-encoded arguments and CBOR response
/// - `req` and `res` specify the HTTP request and response types to be used on the server (these
/// should usually only be necessary if you are integrating with a server other than Actix/Axum)
/// - `impl_from`: specifies whether to implement trait `From` for server function's type or not.
/// By default, if a server function only has one argument, the macro automatically implements the `From` trait
/// to convert from the argument type to the server function type, and vice versa, allowing you to convert
/// between them easily. Setting `impl_from` to `false` disables this, which can be necessary for argument types
/// for which this would create a conflicting implementation. (defaults to `true`)
///
/// ```rust,ignore
/// #[server(
@@ -891,6 +897,7 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// endpoint = "my_fn",
/// input = Cbor,
/// output = Json
/// impl_from = true
/// )]
/// pub async fn my_wacky_server_fn(input: Vec<String>) -> Result<usize, ServerFnError> {
/// todo!()
@@ -900,17 +907,17 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// ## Server Function Encodings
///
/// Server functions are designed to allow a flexible combination of `input` and `output` encodings, the set
/// of which can be found in the [`server_fn::codec`] module.
/// of which can be found in the [`server_fn::codec`](../server_fn/codec/index.html) module.
///
/// The serialization/deserialization process for server functions consists of a series of steps,
/// each of which is represented by a different trait:
/// 1. [`IntoReq`]: The client serializes the [`ServerFn`] argument type into an HTTP request.
/// 2. The [`Client`] sends the request to the server.
/// 3. [`FromReq`]: The server deserializes the HTTP request back into the [`ServerFn`] type.
/// 4. The server calls calls [`ServerFn::run_body`] on the data.
/// 5. [`IntoRes`]: The server serializes the [`ServerFn::Output`] type into an HTTP response.
/// 6. The server integration applies any middleware from [`ServerFn::middlewares`] and responds to the request.
/// 7. [`FromRes`]: The client deserializes the response back into the [`ServerFn::Output`] type.
/// 1. [`IntoReq`](../server_fn/codec/trait.IntoReq.html): The client serializes the [`ServerFn`](../server_fn/trait.ServerFn.html) argument type into an HTTP request.
/// 2. The [`Client`](../server_fn/client/trait.Client.html) sends the request to the server.
/// 3. [`FromReq`](../server_fn/codec/trait.FromReq.html): The server deserializes the HTTP request back into the [`ServerFn`](../server_fn/client/trait.Client.html) type.
/// 4. The server calls calls [`ServerFn::run_body`](../server_fn/trait.ServerFn.html#tymethod.run_body) on the data.
/// 5. [`IntoRes`](../server_fn/codec/trait.IntoRes.html): The server serializes the [`ServerFn::Output`](../server_fn/trait.ServerFn.html#associatedtype.Output) type into an HTTP response.
/// 6. The server integration applies any middleware from [`ServerFn::middleware`](../server_fn/middleware/index.html) and responds to the request.
/// 7. [`FromRes`](../server_fn/codec/trait.FromRes.html): The client deserializes the response back into the [`ServerFn::Output`](../server_fn/trait.ServerFn.html#associatedtype.Output) type.
///
/// Whatever encoding is provided to `input` should implement `IntoReq` and `FromReq`. Whatever encoding is provided
/// to `output` should implement `IntoRes` and `FromRes`.
@@ -932,8 +939,8 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// - **Server functions must return `Result<T, ServerFnError>`.** Even if the work being done
/// inside the function body cant fail, the processes of serialization/deserialization and the
/// network call are fallible.
/// - [`ServerFnError`] can be generic over some custom error type. If so, that type should implement
/// [`FromStr`] and [`Display`], but does not need to implement [`Error`]. This is so the value
/// - [`ServerFnError`](../server_fn/error/enum.ServerFnError.html) can be generic over some custom error type. If so, that type should implement
/// [`FromStr`](std::str::FromStr) and [`Display`](std::fmt::Display), but does not need to implement [`Error`](std::error::Error). This is so the value
/// can be easily serialized and deserialized along with the result.
/// - **Server functions are part of the public API of your application.** A server function is an
/// ad hoc HTTP API endpoint, not a magic formula. Any server function can be accessed by any HTTP

View File

@@ -6,6 +6,7 @@ authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Reactive system for the Leptos web framework."
rust-version.workspace = true
[dependencies]
slotmap = { version = "1", features = ["serde"] }

View File

@@ -86,7 +86,7 @@ use std::any::{Any, TypeId};
///
/// ### Solution
///
/// If you are using the full Leptos framework, you can use the [`Provider`](leptos::Provider)
/// If you are using the full Leptos framework, you can use the [`Provider`](../leptos/fn.Provider.html)
/// component to solve this issue.
///
/// ```rust

View File

@@ -339,11 +339,13 @@ thread_local! {
impl SharedContext {
/// Whether the renderer should currently add hydration IDs.
pub fn no_hydrate() -> bool {
println!("no_hydrate == {}", NO_HYDRATE.with(Cell::get));
NO_HYDRATE.with(Cell::get)
}
/// Sets whether the renderer should not add hydration IDs.
pub fn set_no_hydrate(hydrate: bool) {
println!("set_no_hydrate == {}", hydrate);
NO_HYDRATE.with(|cell| cell.set(hydrate));
}

View File

@@ -125,7 +125,7 @@ use runtime::*;
pub use runtime::{
as_child_of_current_owner, batch, create_runtime, current_runtime,
on_cleanup, run_as_child, set_current_runtime,
spawn_local_with_current_owner, spawn_local_with_owner,
spawn_local_with_current_owner, spawn_local_with_owner, try_batch,
try_spawn_local_with_current_owner, try_spawn_local_with_owner,
try_with_owner, untrack, untrack_with_diagnostics, with_current_owner,
with_owner, Owner, RuntimeId, ScopedFuture,
@@ -143,7 +143,8 @@ pub use suspense::{GlobalSuspenseContext, SuspenseContext};
pub use trigger::*;
pub use watch::*;
pub(crate) fn console_warn(s: &str) {
#[doc(hidden)]
pub fn console_warn(s: &str) {
cfg_if::cfg_if! {
if #[cfg(all(target_arch = "wasm32", any(feature = "csr", feature = "hydrate")))] {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(s));

View File

@@ -86,7 +86,88 @@ pub fn create_memo<T>(f: impl Fn(Option<&T>) -> T + 'static) -> Memo<T>
where
T: PartialEq + 'static,
{
Runtime::current().create_memo(f)
Runtime::current().create_owning_memo(move |current_value| {
let new_value = f(current_value.as_ref());
let is_different = current_value.as_ref() != Some(&new_value);
(new_value, is_different)
})
}
/// Like [`create_memo`], `create_owning_memo` creates an efficient derived reactive value based on
/// other reactive values, but with two differences:
/// 1. The argument to the memo function is owned instead of borrowed.
/// 2. The function must also return whether the value has changed, as the first element of the tuple.
///
/// All of the other caveats and guarantees are the same as the usual "borrowing" memos.
///
/// This type of memo is useful for memos which can avoid computation by re-using the last value,
/// especially slices that need to allocate.
///
/// ```
/// # use leptos_reactive::*;
/// # fn really_expensive_computation(value: i32) -> i32 { value };
/// # let runtime = create_runtime();
/// pub struct State {
/// name: String,
/// token: String,
/// }
///
/// let state = create_rw_signal(State {
/// name: "Alice".to_owned(),
/// token: "abcdef".to_owned(),
/// });
///
/// // If we used `create_memo`, we'd need to allocate every time the state changes, but by using
/// // `create_owning_memo` we can allocate only when `state.name` changes.
/// let name = create_owning_memo(move |old_name| {
/// state.with(move |state| {
/// if let Some(name) =
/// old_name.filter(|old_name| old_name == &state.name)
/// {
/// (name, false)
/// } else {
/// (state.name.clone(), true)
/// }
/// })
/// });
/// let set_name = move |name| state.update(|state| state.name = name);
///
/// // We can also re-use the last allocation even when the value changes, which is usually faster,
/// // but may have some caveats (e.g. if the value size is drastically reduced, the memory will
/// // still be used for the life of the memo).
/// let token = create_owning_memo(move |old_token| {
/// state.with(move |state| {
/// let is_different = old_token.as_ref() != Some(&state.token);
/// let mut token = old_token.unwrap_or_else(String::new);
///
/// if is_different {
/// token.clone_from(&state.token);
/// }
/// (token, is_different)
/// })
/// });
/// let set_token = move |new_token| state.update(|state| state.token = new_token);
/// # runtime.dispose();
/// ```
#[cfg_attr(
any(debug_assertions, feature="ssr"),
instrument(
level = "trace",
skip_all,
fields(
ty = %std::any::type_name::<T>()
)
)
)]
#[track_caller]
#[inline(always)]
pub fn create_owning_memo<T>(
f: impl Fn(Option<T>) -> (T, bool) + 'static,
) -> Memo<T>
where
T: 'static,
{
Runtime::current().create_owning_memo(f)
}
/// An efficient derived reactive value based on other reactive values.
@@ -216,6 +297,65 @@ impl<T> Memo<T> {
{
create_memo(f)
}
/// Creates a new owning memo from the given function.
///
/// This is identical to [`create_owning_memo`].
///
/// ```
/// # use leptos_reactive::*;
/// # fn really_expensive_computation(value: i32) -> i32 { value };
/// # let runtime = create_runtime();
/// pub struct State {
/// name: String,
/// token: String,
/// }
///
/// let state = RwSignal::new(State {
/// name: "Alice".to_owned(),
/// token: "abcdef".to_owned(),
/// });
///
/// // If we used `Memo::new`, we'd need to allocate every time the state changes, but by using
/// // `Memo::new_owning` we can allocate only when `state.name` changes.
/// let name = Memo::new_owning(move |old_name| {
/// state.with(move |state| {
/// if let Some(name) =
/// old_name.filter(|old_name| old_name == &state.name)
/// {
/// (name, false)
/// } else {
/// (state.name.clone(), true)
/// }
/// })
/// });
/// let set_name = move |name| state.update(|state| state.name = name);
///
/// // We can also re-use the last allocation even when the value changes, which is usually faster,
/// // but may have some caveats (e.g. if the value size is drastically reduced, the memory will
/// // still be used for the life of the memo).
/// let token = Memo::new_owning(move |old_token| {
/// state.with(move |state| {
/// let is_different = old_token.as_ref() != Some(&state.token);
/// let mut token = old_token.unwrap_or_else(String::new);
///
/// if is_different {
/// token.clone_from(&state.token);
/// }
/// (token, is_different)
/// })
/// });
/// let set_token = move |new_token| state.update(|state| state.token = new_token);
/// # runtime.dispose();
/// ```
#[inline(always)]
#[track_caller]
pub fn new_owning(f: impl Fn(Option<T>) -> (T, bool) + 'static) -> Memo<T>
where
T: 'static,
{
create_owning_memo(f)
}
}
impl<T> Clone for Memo<T>
@@ -524,8 +664,8 @@ impl_get_fn_traits![Memo];
pub(crate) struct MemoState<T, F>
where
T: PartialEq + 'static,
F: Fn(Option<&T>) -> T,
T: 'static,
F: Fn(Option<T>) -> (T, bool),
{
pub f: F,
pub t: PhantomData<T>,
@@ -535,8 +675,8 @@ where
impl<T, F> AnyComputation for MemoState<T, F>
where
T: PartialEq + 'static,
F: Fn(Option<&T>) -> T,
T: 'static,
F: Fn(Option<T>) -> (T, bool),
{
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
@@ -551,24 +691,16 @@ where
)
)]
fn run(&self, value: Rc<RefCell<dyn Any>>) -> bool {
let (new_value, is_different) = {
let value = value.borrow();
let curr_value = value
.downcast_ref::<Option<T>>()
.expect("to downcast memo value");
let mut value = value.borrow_mut();
let curr_value = value
.downcast_mut::<Option<T>>()
.expect("to downcast memo value");
// run the effect
let new_value = (self.f)(curr_value.as_ref());
let is_different = curr_value.as_ref() != Some(&new_value);
(new_value, is_different)
};
if is_different {
let mut value = value.borrow_mut();
let curr_value = value
.downcast_mut::<Option<T>>()
.expect("to downcast memo value");
*curr_value = Some(new_value);
}
// run the memo
let (new_value, is_different) = (self.f)(curr_value.take());
// set new value
*curr_value = Some(new_value);
is_different
}

View File

@@ -440,7 +440,7 @@ impl<'a> From<Oco<'a, str>> for Oco<'a, [u8]> {
}
}
/// Error returned from [`Oco::try_from`] for unsuccessful
/// Error returned from `Oco::try_from` for unsuccessful
/// conversion from `Oco<'_, [u8]>` to `Oco<'_, str>`.
#[derive(Debug, Clone, thiserror::Error)]
#[error("invalid utf-8 sequence: {_0}")]

View File

@@ -1003,11 +1003,11 @@ where
/// // when we read the signal, it contains either
/// // 1) None (if the Future isn't ready yet) or
/// // 2) Some(T) (if the future's already resolved)
/// assert_eq!(cats.read(), Some(vec!["1".to_string()]));
/// assert_eq!(cats.get(), Some(vec!["1".to_string()]));
///
/// // when the signal's value changes, the `Resource` will generate and run a new `Future`
/// set_how_many_cats.set(2);
/// assert_eq!(cats.read(), Some(vec!["2".to_string()]));
/// assert_eq!(cats.get(), Some(vec!["2".to_string()]));
/// # }
/// # runtime.dispose();
/// ```

View File

@@ -1201,12 +1201,12 @@ impl RuntimeId {
#[track_caller]
#[inline(always)]
pub(crate) fn create_memo<T>(
pub(crate) fn create_owning_memo<T>(
self,
f: impl Fn(Option<&T>) -> T + 'static,
f: impl Fn(Option<T>) -> (T, bool) + 'static,
) -> Memo<T>
where
T: PartialEq + Any + 'static,
T: 'static,
{
Memo {
id: self.create_concrete_memo(
@@ -1397,12 +1397,30 @@ impl Drop for SetObserverOnDrop {
///
/// # Panics
/// Panics if the runtime has already been disposed.
///
/// To avoid panicking under any circumstances, use [`try_batch`].
#[cfg_attr(
any(debug_assertions, features = "ssr"),
instrument(level = "trace", skip_all,)
)]
#[inline(always)]
pub fn batch<T>(f: impl FnOnce() -> T) -> T {
try_batch(f).expect(
"tried to run a batched update in a runtime that has been disposed",
)
}
/// Attempts to batch any reactive updates, preventing effects from running until the whole
/// function has run. This allows you to prevent rerunning effects if multiple
/// signal updates might cause the same effect to run.
///
/// Unlike [`batch`], this will not panic if the runtime has been disposed.
#[cfg_attr(
any(debug_assertions, features = "ssr"),
instrument(level = "trace", skip_all,)
)]
#[inline(always)]
pub fn try_batch<T>(f: impl FnOnce() -> T) -> Result<T, ReactiveSystemError> {
with_runtime(move |runtime| {
let batching = SetBatchingOnDrop(runtime.batching.get());
runtime.batching.set(true);
@@ -1415,7 +1433,6 @@ pub fn batch<T>(f: impl FnOnce() -> T) -> T {
runtime.run_effects();
val
})
.expect("tried to run a batched update in a runtime that has been disposed")
}
struct SetBatchingOnDrop(bool);

View File

@@ -212,3 +212,84 @@ fn dynamic_dependencies() {
runtime.dispose();
}
#[test]
fn owning_memo_slice() {
use std::rc::Rc;
let runtime = create_runtime();
// this could be serialized to and from localstorage with miniserde
pub struct State {
name: String,
token: String,
}
let state = create_rw_signal(State {
name: "Alice".to_owned(),
token: "is this a token????".to_owned(),
});
// We can allocate only when `state.name` changes
let name = create_owning_memo(move |old_name| {
state.with(move |state| {
if let Some(name) =
old_name.filter(|old_name| old_name == &state.name)
{
(name, false)
} else {
(state.name.clone(), true)
}
})
});
let set_name = move |name| state.update(|state| state.name = name);
// We can also re-use the last token allocation, which may be even better if the tokens are
// always of the same length
let token = create_owning_memo(move |old_token| {
state.with(move |state| {
let is_different = old_token.as_ref() != Some(&state.token);
let mut token = old_token.unwrap_or_else(String::new);
if is_different {
token.clone_from(&state.token);
}
(token, is_different)
})
});
let set_token =
move |new_token| state.update(|state| state.token = new_token);
let count_name_updates = Rc::new(std::cell::Cell::new(0));
assert_eq!(count_name_updates.get(), 0);
create_isomorphic_effect({
let count_name_updates = Rc::clone(&count_name_updates);
move |_| {
name.track();
count_name_updates.set(count_name_updates.get() + 1);
}
});
assert_eq!(count_name_updates.get(), 1);
let count_token_updates = Rc::new(std::cell::Cell::new(0));
assert_eq!(count_token_updates.get(), 0);
create_isomorphic_effect({
let count_token_updates = Rc::clone(&count_token_updates);
move |_| {
token.track();
count_token_updates.set(count_token_updates.get() + 1);
}
});
assert_eq!(count_token_updates.get(), 1);
set_name("Bob".to_owned());
name.with(|name| assert_eq!(name, "Bob"));
assert_eq!(count_name_updates.get(), 2);
assert_eq!(count_token_updates.get(), 1);
set_token("this is not a token!".to_owned());
token.with(|token| assert_eq!(token, "this is not a token!"));
assert_eq!(count_name_updates.get(), 2);
assert_eq!(count_token_updates.get(), 2);
runtime.dispose();
}

View File

@@ -7,6 +7,7 @@ license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "RPC for the Leptos web framework."
readme = "../README.md"
rust-version.workspace = true
[dependencies]
leptos_reactive = { workspace = true }

View File

@@ -1,7 +1,10 @@
//use crate::{ServerFn, ServerFnError};
#[cfg(debug_assertions)]
use leptos_reactive::console_warn;
use leptos_reactive::{
batch, create_rw_signal, is_suppressing_resource_load, signal_prelude::*,
spawn_local, store_value, use_context, ReadSignal, RwSignal, StoredValue,
create_rw_signal, is_suppressing_resource_load, signal_prelude::*,
spawn_local, store_value, try_batch, use_context, ReadSignal, RwSignal,
StoredValue,
};
use server_fn::{error::ServerFnUrlError, ServerFn, ServerFnError};
use std::{cell::Cell, future::Future, pin::Pin, rc::Rc};
@@ -93,14 +96,24 @@ where
any(debug_assertions, feature = "ssr"),
tracing::instrument(level = "trace", skip_all,)
)]
#[track_caller]
pub fn dispatch(&self, input: I) {
self.0.with_value(|a| a.dispatch(input))
#[cfg(debug_assertions)]
let loc = std::panic::Location::caller();
self.0.with_value(|a| {
a.dispatch(
input,
#[cfg(debug_assertions)]
loc,
)
})
}
/// Create an [Action].
///
/// [Action] is a type of [Signal] which represent imperative calls to
/// an asynchronous function. Where a [Resource] is driven as a function
/// an asynchronous function. Where a [Resource](leptos_reactive::Resource) is driven as a function
/// of a [Signal], [Action]s are [Action::dispatch]ed by events or handlers.
///
/// ```rust
@@ -229,7 +242,7 @@ impl<I> Action<I, Result<I::Output, ServerFnError<I::Error>>>
where
I: ServerFn + 'static,
{
/// Create an [Action] to imperatively call a [server_fn::server] function.
/// Create an [Action] to imperatively call a [server](leptos_macro::server) function.
///
/// The struct representing your server function's arguments should be
/// provided to the [Action]. Unless specified as an argument to the server
@@ -366,7 +379,11 @@ where
any(debug_assertions, feature = "ssr"),
tracing::instrument(level = "trace", skip_all,)
)]
pub fn dispatch(&self, input: I) {
pub fn dispatch(
&self,
input: I,
#[cfg(debug_assertions)] loc: &'static std::panic::Location<'static>,
) {
if !is_suppressing_resource_load() {
let fut = (self.action_fn)(&input);
self.input.set(Some(input));
@@ -379,7 +396,7 @@ where
pending_dispatches.set(pending_dispatches.get().saturating_sub(1));
spawn_local(async move {
let new_value = fut.await;
batch(move || {
let res = try_batch(move || {
value.set(Some(new_value));
input.set(None);
version.update(|n| *n += 1);
@@ -389,6 +406,18 @@ where
pending.set(false);
}
});
if res.is_err() {
#[cfg(debug_assertions)]
console_warn(&format!(
"At {loc}, you are dispatching an action in a runtime \
that has already been disposed. This may be because \
you are calling `.dispatch()` in the body of a \
component, during initial server-side rendering. If \
that's the case, you should probably be using \
`create_resource` instead of `create_action`."
));
}
})
}
}

View File

@@ -1,11 +1,12 @@
[package]
name = "leptos_meta"
version = "0.6.5"
version = "0.6.8"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Tools to set HTML metadata in the Leptos web framework."
rust-version.workspace = true
[dependencies]
cfg-if = "1"

View File

@@ -16,6 +16,9 @@
//!
//! #[component]
//! fn MyApp() -> impl IntoView {
//! // Provides a [`MetaContext`], if there is not already one provided.
//! provide_meta_context();
//!
//! let (name, set_name) = create_signal("Alice".to_string());
//!
//! view! {

View File

@@ -1,12 +1,13 @@
[package]
name = "leptos_router"
version = "0.6.5"
version = "0.6.8"
edition = "2021"
authors = ["Greg Johnston"]
authors = ["Greg Johnston", "Ben Wishovich"]
license = "MIT"
readme = "../README.md"
repository = "https://github.com/leptos-rs/leptos"
description = "Router for the Leptos web framework."
rust-version.workspace = true
[dependencies]
leptos = { workspace = true }

View File

@@ -1,6 +1,6 @@
use crate::{
hooks::has_router, use_navigate, use_resolved_path, NavigateOptions,
ToHref, Url,
hooks::has_router, resolve_redirect_url, use_navigate, use_resolved_path,
NavigateOptions, ToHref, Url,
};
use leptos::{
html::form,
@@ -447,8 +447,10 @@ where
{
let has_router = has_router();
if !has_router {
_ = server_fn::redirect::set_redirect_hook(|path: &str| {
_ = window().location().set_href(path);
_ = server_fn::redirect::set_redirect_hook(|loc: &str| {
if let Some(url) = resolve_redirect_url(loc) {
_ = window().location().set_href(&url.href());
}
});
}
let action_url = if let Some(url) = action.url() {
@@ -478,6 +480,10 @@ where
action.dispatch(new_input);
}
Err(err) => {
error!(
"Error converting form field into server function \
arguments: {err:?}"
);
batch(move || {
value.set(Some(Err(ServerFnError::Serialization(
err.to_string(),
@@ -541,8 +547,10 @@ where
{
let has_router = has_router();
if !has_router {
_ = server_fn::redirect::set_redirect_hook(|path: &str| {
_ = window().location().set_href(path);
_ = server_fn::redirect::set_redirect_hook(|loc: &str| {
if let Some(url) = resolve_redirect_url(loc) {
_ = window().location().set_href(&url.href());
}
});
}
let action_url = if let Some(url) = action.url() {

View File

@@ -1,6 +1,7 @@
use crate::{
matching::{resolve_path, PathMatch, RouteDefinition, RouteMatch},
ParamsMap, RouterContext, SsrMode, StaticData, StaticMode, StaticParamsMap,
TrailingSlash,
};
use leptos::{leptos_dom::Transparent, *};
use std::{
@@ -17,6 +18,16 @@ thread_local! {
static ROUTE_ID: Cell<usize> = const { Cell::new(0) };
}
// RouteDefinition.id is `pub` and required to be unique.
// Should we make this public so users can generate unique IDs?
pub(in crate::components) fn new_route_id() -> usize {
ROUTE_ID.with(|id| {
let next = id.get() + 1;
id.set(next);
next
})
}
/// Represents an HTTP method that can be handled by this route.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum Method {
@@ -65,6 +76,11 @@ pub fn Route<E, F, P>(
/// accessed with [`use_route_data`](crate::use_route_data).
#[prop(optional, into)]
data: Option<Loader>,
/// How this route should handle trailing slashes in its path.
/// Overrides any setting applied to [`crate::components::Router`].
/// Serves as a default for any inner Routes.
#[prop(optional)]
trailing_slash: Option<TrailingSlash>,
/// `children` may be empty or include nested routes.
#[prop(optional)]
children: Option<Children>,
@@ -83,6 +99,7 @@ where
data,
None,
None,
trailing_slash,
)
}
@@ -115,6 +132,11 @@ pub fn ProtectedRoute<P, E, F, C>(
/// accessed with [`use_route_data`](crate::use_route_data).
#[prop(optional, into)]
data: Option<Loader>,
/// How this route should handle trailing slashes in its path.
/// Overrides any setting applied to [`crate::components::Router`].
/// Serves as a default for any inner Routes.
#[prop(optional)]
trailing_slash: Option<TrailingSlash>,
/// `children` may be empty or include nested routes.
#[prop(optional)]
children: Option<Children>,
@@ -143,6 +165,7 @@ where
data,
None,
None,
trailing_slash,
)
}
@@ -171,6 +194,11 @@ pub fn StaticRoute<E, F, P, S>(
/// accessed with [`use_route_data`](crate::use_route_data).
#[prop(optional, into)]
data: Option<Loader>,
/// How this route should handle trailing slashes in its path.
/// Overrides any setting applied to [`crate::components::Router`].
/// Serves as a default for any inner Routes.
#[prop(optional)]
trailing_slash: Option<TrailingSlash>,
/// `children` may be empty or include nested routes.
#[prop(optional)]
children: Option<Children>,
@@ -193,6 +221,7 @@ where
data,
Some(mode),
Some(Arc::new(static_params)),
trailing_slash,
)
}
@@ -210,6 +239,7 @@ pub(crate) fn define_route(
data: Option<Loader>,
static_mode: Option<StaticMode>,
static_params: Option<StaticData>,
trailing_slash: Option<TrailingSlash>,
) -> RouteDefinition {
let children = children
.map(|children| {
@@ -226,14 +256,8 @@ pub(crate) fn define_route(
})
.unwrap_or_default();
let id = ROUTE_ID.with(|id| {
let next = id.get() + 1;
id.set(next);
next
});
RouteDefinition {
id,
id: new_route_id(),
path,
children,
view,
@@ -242,6 +266,7 @@ pub(crate) fn define_route(
data,
static_mode,
static_params,
trailing_slash,
}
}
@@ -385,6 +410,11 @@ impl RouteContext {
pub fn outlet(&self) -> impl IntoView {
(self.inner.outlet)()
}
/// The http method used to navigate to this route. Defaults to [`Method::Get`] when unavailable like in client side routing
pub fn method(&self) -> Method {
use_context().unwrap_or_default()
}
}
pub(crate) struct RouteContextInner {

View File

@@ -1,7 +1,7 @@
use crate::{
create_location, matching::resolve_path, scroll_to_el, use_location,
use_navigate, Branch, History, Location, LocationChange, RouteContext,
RouterIntegrationContext, State,
create_location, matching::resolve_path, resolve_redirect_url,
scroll_to_el, use_location, use_navigate, Branch, History, Location,
LocationChange, RouteContext, RouterIntegrationContext, State,
};
#[cfg(not(feature = "ssr"))]
use crate::{unescape, Url};
@@ -24,6 +24,7 @@ use std::{
use thiserror::Error;
#[cfg(not(feature = "ssr"))]
use wasm_bindgen::JsCast;
use wasm_bindgen::UnwrapThrowExt;
static GLOBAL_ROUTERS_COUNT: AtomicUsize = AtomicUsize::new(0);
@@ -40,13 +41,16 @@ pub fn Router(
/// A signal that will be set while the navigation process is underway.
#[prop(optional, into)]
set_is_routing: Option<SignalSetter<bool>>,
/// How trailing slashes should be handled in [`Route`] paths.
#[prop(optional)]
trailing_slash: TrailingSlash,
/// The `<Router/>` should usually wrap your whole page. It can contain
/// any elements, and should include a [`Routes`](crate::Routes) component somewhere
/// to define and display [`Route`](crate::Route)s.
children: Children,
) -> impl IntoView {
// create a new RouterContext and provide it to every component beneath the router
let router = RouterContext::new(base, fallback);
let router = RouterContext::new(base, fallback, trailing_slash);
provide_context(router);
provide_context(GlobalSuspenseContext::new());
if let Some(set_is_routing) = set_is_routing {
@@ -56,15 +60,24 @@ pub fn Router(
// set server function redirect hook
let navigate = use_navigate();
let navigate = SendWrapper::new(navigate);
let router_hook = Box::new(move |path: &str| {
let path = path.to_string();
// delay by a tick here, so that the Action updates *before* the redirect
request_animation_frame({
let router_hook = Box::new(move |loc: &str| {
let Some(url) = resolve_redirect_url(loc) else {
return; // resolve_redirect_url() already logs an error
};
let current_origin =
leptos_dom::helpers::location().origin().unwrap_throw();
if url.origin() == current_origin {
let navigate = navigate.clone();
move || {
navigate(&path, Default::default());
}
});
// delay by a tick here, so that the Action updates *before* the redirect
request_animation_frame(move || {
navigate(&url.href(), Default::default());
});
// Use set_href() if the conditions for client-side navigation were not satisfied
} else if let Err(e) =
leptos_dom::helpers::location().set_href(&url.href())
{
leptos::logging::error!("Failed to redirect: {e:#?}");
}
}) as RedirectHook;
_ = server_fn::redirect::set_redirect_hook(router_hook);
@@ -93,6 +106,7 @@ pub(crate) struct RouterContextInner {
id: usize,
pub location: Location,
pub base: RouteContext,
trailing_slash: TrailingSlash,
pub possible_routes: RefCell<Option<Vec<Branch>>>,
#[allow(unused)] // used in CSR/hydrate
base_path: String,
@@ -129,6 +143,7 @@ impl RouterContext {
pub(crate) fn new(
base: Option<&'static str>,
fallback: Option<fn() -> View>,
trailing_slash: TrailingSlash,
) -> Self {
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
@@ -210,6 +225,7 @@ impl RouterContext {
path_stack: store_value(vec![location.pathname.get_untracked()]),
location,
base,
trailing_slash,
history: Box::new(history),
reference,
@@ -248,6 +264,10 @@ impl RouterContext {
self.inner.id
}
pub(crate) fn trailing_slash(&self) -> TrailingSlash {
self.inner.trailing_slash.clone()
}
/// A list of all possible routes this router can match.
pub fn possible_branches(&self) -> Vec<Branch> {
self.inner
@@ -505,3 +525,64 @@ impl Default for NavigateOptions {
}
}
}
/// Declares how you would like to handle trailing slashes in Route paths. This
/// can be set on [`Router`] and overridden in [`crate::components::Route`]
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub enum TrailingSlash {
/// This is the default behavior as of Leptos 0.5. Trailing slashes in your
/// `Route` path are stripped. i.e.: the following two route declarations
/// are equivalent:
/// * `<Route path="/foo">`
/// * `<Route path="/foo/">`
#[default]
Drop,
/// This mode will respect your path as it is written. Ex:
/// * If you specify `<Route path="/foo">`, then `/foo` matches, but
/// `/foo/` does not.
/// * If you specify `<Route path="/foo/">`, then `/foo/` matches, but
/// `/foo` does not.
Exact,
/// Like `Exact`, this mode respects your path as-written. But it will also
/// add redirects to the specified path if a user nagivates to a URL that is
/// off by only the trailing slash.
///
/// Given `<Route path="/foo">`
/// * Visiting `/foo` is valid.
/// * Visiting `/foo/` serves a redirect to `/foo`
///
/// Given `<Route path="/foo/">`
/// * Visiting `/foo` serves a redirect to `/foo/`
/// * Visiting `/foo/` is valid.
Redirect,
}
impl TrailingSlash {
/// Should we redirect requests that come in with the wrong (extra/missing) trailng slash?
pub(crate) fn should_redirect(&self) -> bool {
use TrailingSlash::*;
match self {
Redirect => true,
Drop | Exact => false,
}
}
pub(crate) fn normalize_route_path(&self, path: &mut String) {
if !self.should_drop() {
return;
}
while path.ends_with('/') {
path.pop();
}
}
fn should_drop(&self) -> bool {
use TrailingSlash::*;
match self {
Redirect | Exact => false,
Drop => true,
}
}
}

View File

@@ -1,10 +1,12 @@
use crate::{
animation::*,
components::route::new_route_id,
matching::{
expand_optionals, get_route_matches, join_paths, Branch, Matcher,
RouteDefinition, RouteMatch,
},
use_is_back_navigation, RouteContext, RouterContext, SetIsRouting,
use_is_back_navigation, use_route, NavigateOptions, Redirect, RouteContext,
RouterContext, SetIsRouting, TrailingSlash,
};
use leptos::{leptos_dom::HydrationCtx, *};
use std::{
@@ -82,7 +84,7 @@ pub fn Routes(
let base_route = router.base();
let base = base.unwrap_or_default();
Branches::initialize(router_id, &base, children());
Branches::initialize(&router, &base, children());
#[cfg(feature = "ssr")]
if let Some(context) = use_context::<crate::PossibleBranchContext>() {
@@ -164,7 +166,7 @@ pub fn AnimatedRoutes(
let base_route = router.base();
let base = base.unwrap_or_default();
Branches::initialize(router_id, &base, children());
Branches::initialize(&router, &base, children());
#[cfg(feature = "ssr")]
if let Some(context) = use_context::<crate::PossibleBranchContext>() {
@@ -278,7 +280,7 @@ thread_local! {
}
impl Branches {
pub fn initialize(router_id: usize, base: &str, children: Fragment) {
pub fn initialize(router: &RouterContext, base: &str, children: Fragment) {
BRANCHES.with(|branches| {
#[cfg(debug_assertions)]
{
@@ -293,9 +295,9 @@ impl Branches {
}
let mut current = branches.borrow_mut();
if !current.contains_key(&(router_id, Cow::from(base))) {
if !current.contains_key(&(router.id(), Cow::from(base))) {
let mut branches = Vec::new();
let children = children
let mut children = children
.as_children()
.iter()
.filter_map(|child| {
@@ -315,6 +317,7 @@ impl Branches {
})
.cloned()
.collect::<Vec<_>>();
inherit_settings(&mut children, router);
create_branches(
&children,
base,
@@ -323,7 +326,7 @@ impl Branches {
true,
base,
);
current.insert((router_id, Cow::Owned(base.into())), branches);
current.insert((router.id(), Cow::Owned(base.into())), branches);
}
})
}
@@ -344,6 +347,36 @@ impl Branches {
}
}
// <Route>s may inherit settings from each other or <Router>.
// This mutates RouteDefinitions to propagate those settings.
fn inherit_settings(children: &mut [RouteDefinition], router: &RouterContext) {
struct InheritProps {
trailing_slash: Option<TrailingSlash>,
}
fn route_def_inherit(
children: &mut [RouteDefinition],
inherited: InheritProps,
) {
for child in children {
if child.trailing_slash.is_none() {
child.trailing_slash = inherited.trailing_slash.clone();
}
route_def_inherit(
&mut child.children,
InheritProps {
trailing_slash: child.trailing_slash.clone(),
},
);
}
}
route_def_inherit(
children,
InheritProps {
trailing_slash: Some(router.trailing_slash()),
},
);
}
fn route_states(
router_id: usize,
base: String,
@@ -553,6 +586,7 @@ pub(crate) struct RouterState {
#[derive(Debug, Clone, PartialEq)]
pub struct RouteData {
// This ID is always the same as key.id. Deprecate?
pub id: usize,
pub key: RouteDefinition,
pub pattern: String,
@@ -647,24 +681,110 @@ fn create_routes(
parents_path, route_def.path
);
}
let trailing_slash = route_def
.trailing_slash
.clone()
.expect("trailng_slash should be set by this point");
let mut acc = Vec::new();
for original_path in expand_optionals(&route_def.path) {
let path = join_paths(base, &original_path);
let mut path = join_paths(base, &original_path).to_string();
trailing_slash.normalize_route_path(&mut path);
let pattern = if is_leaf {
path
} else if let Some((path, _splat)) = path.split_once("/*") {
path.to_string()
} else {
path.split("/*")
.next()
.map(|n| n.to_string())
.unwrap_or(path)
path
};
acc.push(RouteData {
let route_data = RouteData {
key: route_def.clone(),
id: route_def.id,
matcher: Matcher::new_with_partial(&pattern, !is_leaf),
pattern,
original_path: original_path.into_owned(),
});
};
if route_data.matcher.is_wildcard() {
// already handles trailing_slash
} else if let Some(redirect_route) = redirect_route_for(route_def) {
let pattern = &redirect_route.path;
let redirect_route_data = RouteData {
id: redirect_route.id,
matcher: Matcher::new_with_partial(pattern, !is_leaf),
pattern: pattern.to_owned(),
original_path: pattern.to_owned(),
key: redirect_route,
};
acc.push(redirect_route_data);
}
acc.push(route_data);
}
acc
}
/// A new route that redirects to `route` with the correct trailng slash.
fn redirect_route_for(route: &RouteDefinition) -> Option<RouteDefinition> {
if matches!(route.path.as_str(), "" | "/") {
// Root paths are an exception to the rule and are always equivalent:
return None;
}
let trailing_slash = route
.trailing_slash
.clone()
.expect("trailing_slash should be defined by now");
if !trailing_slash.should_redirect() {
return None;
}
// Are we creating a new route that adds or removes a slash?
let add_slash = route.path.ends_with('/');
let view = Rc::new(move || {
view! {
<FixTrailingSlash add_slash />
}
.into_view()
});
let new_pattern = if add_slash {
// If we need to add a slash, we need to match on the path w/o it:
route.path.trim_end_matches('/').to_string()
} else {
format!("{}/", route.path)
};
let new_route = RouteDefinition {
path: new_pattern,
children: vec![],
data: None,
methods: route.methods,
id: new_route_id(),
view,
ssr_mode: route.ssr_mode,
static_mode: route.static_mode,
static_params: None,
trailing_slash: None, // Shouldn't be needed/used from here on out
};
Some(new_route)
}
#[component]
fn FixTrailingSlash(add_slash: bool) -> impl IntoView {
let route = use_route();
let path = if add_slash {
format!("{}/", route.path())
} else {
route.path().trim_end_matches('/').to_string()
};
let options = NavigateOptions {
replace: true,
..Default::default()
};
view! {
<Redirect path options/>
}
}

View File

@@ -1,6 +1,9 @@
mod test_extract_routes;
use crate::{
Branch, Method, RouterIntegrationContext, ServerIntegration, SsrMode,
StaticDataMap, StaticMode, StaticParamsMap, StaticPath,
provide_server_redirect, Branch, Method, RouterIntegrationContext,
ServerIntegration, SsrMode, StaticDataMap, StaticMode, StaticParamsMap,
StaticPath,
};
use leptos::*;
use std::{
@@ -42,6 +45,9 @@ impl RouteListing {
}
/// The path this route handles.
///
/// This should be formatted for whichever web server integegration is being used. (ex: leptos-actix.)
/// When returned from leptos-router, it matches `self.leptos_path()`.
pub fn path(&self) -> &str {
&self.path
}
@@ -128,21 +134,9 @@ where
{
let runtime = create_runtime();
let integration = ServerIntegration {
path: "http://leptos.rs/".to_string(),
};
provide_context(RouterIntegrationContext::new(integration));
let branches = PossibleBranchContext::default();
provide_context(branches.clone());
additional_context();
leptos::suppress_resource_load(true);
_ = app_fn().into_view();
leptos::suppress_resource_load(false);
let branches = get_branches(app_fn, additional_context);
let branches = branches.0.borrow();
let mut static_data_map: StaticDataMap = HashMap::new();
let routes = branches
.iter()
@@ -182,3 +176,29 @@ where
runtime.dispose();
(routes, static_data_map)
}
fn get_branches<IV>(
app_fn: impl Fn() -> IV + 'static + Clone,
additional_context: impl Fn() + 'static + Clone,
) -> PossibleBranchContext
where
IV: IntoView + 'static,
{
let integration = ServerIntegration {
path: "http://leptos.rs/".to_string(),
};
provide_context(RouterIntegrationContext::new(integration));
let branches = PossibleBranchContext::default();
provide_context(branches.clone());
// Suppress startup warning about using <Redirect/> without ServerRedirectFunction:
provide_server_redirect(|_str| ());
additional_context();
leptos::suppress_resource_load(true);
_ = app_fn().into_view();
leptos::suppress_resource_load(false);
branches
}

View File

@@ -0,0 +1,258 @@
// This is here, vs /router/tests/, because it accesses some `pub(crate)`
// features to test crate internals that wouldn't be available there.
#![cfg(all(test, feature = "ssr"))]
use crate::*;
use itertools::Itertools;
use leptos::*;
use std::{cell::RefCell, rc::Rc};
#[component]
fn DefaultApp() -> impl IntoView {
let view = || view! { "" };
view! {
<Router>
<Routes>
<Route path="/foo" view/>
<Route path="/bar/" view/>
<Route path="/baz/:id" view/>
<Route path="/name/:name/" view/>
<Route path="/any/*any" view/>
</Routes>
</Router>
}
}
#[component]
fn ExactApp() -> impl IntoView {
let view = || view! { "" };
let trailing_slash = TrailingSlash::Exact;
view! {
<Router trailing_slash>
<Routes>
<Route path="/foo" view/>
<Route path="/bar/" view/>
<Route path="/baz/:id" view/>
<Route path="/name/:name/" view/>
<Route path="/any/*any" view/>
</Routes>
</Router>
}
}
#[component]
fn RedirectApp() -> impl IntoView {
let view = || view! { "" };
let trailing_slash = TrailingSlash::Redirect;
view! {
<Router trailing_slash>
<Routes>
<Route path="/foo" view/>
<Route path="/bar/" view/>
<Route path="/baz/:id" view/>
<Route path="/name/:name/" view/>
<Route path="/any/*any" view/>
</Routes>
</Router>
}
}
#[test]
fn test_generated_routes_default() {
// By default, we use the behavior as of Leptos 0.5, which is equivalent to TrailingSlash::Drop.
assert_generated_paths(
DefaultApp,
&["/any/*any", "/bar", "/baz/:id", "/foo", "/name/:name"],
);
}
#[test]
fn test_generated_routes_exact() {
// Allow users to precisely define whether slashes are present:
assert_generated_paths(
ExactApp,
&["/any/*any", "/bar/", "/baz/:id", "/foo", "/name/:name/"],
);
}
#[test]
fn test_generated_routes_redirect() {
// TralingSlashes::Redirect generates paths to redirect to the path with the "correct" trailing slash ending (or lack thereof).
assert_generated_paths(
RedirectApp,
&[
"/any/*any",
"/bar",
"/bar/",
"/baz/:id",
"/baz/:id/",
"/foo",
"/foo/",
"/name/:name",
"/name/:name/",
],
)
}
#[test]
fn test_rendered_redirect() {
// Given an app that uses TrailngSlsahes::Redirect, rendering the redirected path
// should render the redirect. Other paths should not.
let expected_redirects = &[
("/bar", "/bar/"),
("/baz/some_id/", "/baz/some_id"),
("/name/some_name", "/name/some_name/"),
("/foo/", "/foo"),
];
let redirect_result = Rc::new(RefCell::new(Option::None));
let rc = redirect_result.clone();
let server_redirect = move |new_value: &str| {
rc.replace(Some(new_value.to_string()));
};
let _runtime = Disposable(create_runtime());
let history = TestHistory::new("/");
provide_context(RouterIntegrationContext::new(history.clone()));
provide_server_redirect(server_redirect);
// We expect these redirects to exist:
for (src, dest) in expected_redirects {
let loc = format!("https://example.com{src}");
history.goto(&loc);
redirect_result.replace(None);
RedirectApp().into_view().render_to_string();
let redirected_to = redirect_result.borrow().clone();
assert!(
redirected_to.is_some(),
"Should redirect from {src} to {dest}"
);
assert_eq!(redirected_to.unwrap(), *dest);
}
// But the destination paths shouldn't themselves redirect:
redirect_result.replace(None);
for (_src, dest) in expected_redirects {
let loc = format!("https://example.com{dest}");
history.goto(&loc);
RedirectApp().into_view().render_to_string();
let redirected_to = redirect_result.borrow().clone();
assert!(
redirected_to.is_none(),
"Destination of redirect shouldn't also redirect: {dest}"
);
}
}
struct Disposable(RuntimeId);
// If the test fails, and we don't dispose, we get irrelevant panics.
impl Drop for Disposable {
fn drop(&mut self) {
self.0.dispose()
}
}
#[derive(Clone)]
struct TestHistory {
loc: RwSignal<LocationChange>,
}
impl TestHistory {
fn new(initial: &str) -> Self {
let lc = LocationChange {
value: initial.to_owned(),
..Default::default()
};
Self {
loc: create_rw_signal(lc),
}
}
fn goto(&self, loc: &str) {
let change = LocationChange {
value: loc.to_string(),
..Default::default()
};
self.navigate(&change);
}
}
impl History for TestHistory {
fn location(&self) -> ReadSignal<LocationChange> {
self.loc.read_only()
}
fn navigate(&self, new_loc: &LocationChange) {
self.loc.update(|loc| loc.value = new_loc.value.clone())
}
}
// WARNING!
//
// Despite generate_route_list_inner() using a new leptos_reactive::RuntimeID
// each time we call this function, somehow Routes are leaked between different
// apps. To avoid that, make sure to put each call in a separate #[test] method.
//
// TODO: Better isolation for different apps to avoid this issue?
fn assert_generated_paths<F, IV>(app: F, expected_sorted_paths: &[&str])
where
F: Clone + Fn() -> IV + 'static,
IV: IntoView + 'static,
{
let (routes, static_data) = generate_route_list_inner(app);
let mut paths = routes.iter().map(|route| route.path()).collect_vec();
paths.sort();
assert_eq!(paths, expected_sorted_paths);
let mut keys = static_data.keys().collect_vec();
keys.sort();
assert_eq!(paths, keys);
// integrations can update "path" to be valid for themselves, but
// when routes are returned by leptos_router, these are equal:
assert!(routes
.iter()
.all(|route| route.path() == route.leptos_path()));
}
#[test]
fn test_unique_route_ids() {
let branches = get_branches(RedirectApp);
assert!(!branches.is_empty());
assert!(branches
.iter()
.flat_map(|branch| &branch.routes)
.map(|route| route.id)
.all_unique());
}
#[test]
fn test_unique_route_patterns() {
let branches = get_branches(RedirectApp);
assert!(!branches.is_empty());
assert!(branches
.iter()
.flat_map(|branch| &branch.routes)
.map(|route| route.pattern.as_str())
.all_unique());
}
fn get_branches<F, IV>(app_fn: F) -> Vec<Branch>
where
F: Fn() -> IV + Clone + 'static,
IV: IntoView + 'static,
{
let runtime = create_runtime();
let additional_context = || ();
let branches = super::get_branches(app_fn, additional_context);
let branches = branches.0.borrow().clone();
runtime.dispose();
branches
}

View File

@@ -3,8 +3,8 @@ use crate::{
RouterContext,
};
use leptos::{
create_memo, request_animation_frame, signal_prelude::*, use_context, Memo,
Oco,
create_memo, request_animation_frame, signal_prelude::*, use_context,
window, Memo, Oco,
};
use std::{rc::Rc, str::FromStr};
@@ -216,3 +216,28 @@ pub(crate) fn use_is_back_navigation() -> ReadSignal<bool> {
let router = use_router();
router.inner.is_back.read_only()
}
/// Resolves a redirect location to an (absolute) URL.
pub(crate) fn resolve_redirect_url(loc: &str) -> Option<web_sys::Url> {
let origin = match window().location().origin() {
Ok(origin) => origin,
Err(e) => {
leptos::logging::error!("Failed to get origin: {:#?}", e);
return None;
}
};
// TODO: Use server function's URL as base instead.
let base = origin;
match web_sys::Url::new_with_base(loc, &base) {
Ok(url) => Some(url),
Err(e) => {
leptos::logging::error!(
"Invalid redirect location: {}",
e.as_string().unwrap_or_default(),
);
None
}
}
}

View File

@@ -31,14 +31,8 @@ impl Matcher {
Some((p, s)) => (p, Some(s.to_string())),
None => (path, None),
};
let segments = pattern
.split('/')
.filter(|n| !n.is_empty())
.map(|n| n.to_string())
.collect::<Vec<_>>();
let segments: Vec<String> = get_segments(pattern);
let len = segments.len();
Self {
splat,
segments,
@@ -49,10 +43,7 @@ impl Matcher {
#[doc(hidden)]
pub fn test(&self, location: &str) -> Option<PathMatch> {
let loc_segments = location
.split('/')
.filter(|n| !n.is_empty())
.collect::<Vec<_>>();
let loc_segments: Vec<&str> = get_segments(location);
let loc_len = loc_segments.len();
let len_diff: i32 = loc_len as i32 - self.len as i32;
@@ -107,4 +98,24 @@ impl Matcher {
Some(PathMatch { path, params })
}
}
#[doc(hidden)]
pub(crate) fn is_wildcard(&self) -> bool {
self.splat.is_some()
}
}
fn get_segments<'a, S: From<&'a str>>(pattern: &'a str) -> Vec<S> {
// URL root paths ("/" and "") are equivalent and treated as 0-segment paths.
// non-root paths with trailing slashes get extra empty segment at the end.
// This makes sure that segment matching is trailing-slash sensitive.
let mut segments: Vec<S> = pattern
.split('/')
.filter(|p| !p.is_empty())
.map(Into::into)
.collect();
if !segments.is_empty() && pattern.ends_with('/') {
segments.push("".into());
}
segments
}

View File

@@ -51,7 +51,14 @@ fn has_scheme(path: &str) -> bool {
#[doc(hidden)]
fn normalize(path: &str, omit_slash: bool) -> Cow<'_, str> {
let s = path.trim_start_matches('/').trim_end_matches('/');
let s = path.trim_start_matches('/');
let trim_end = s
.chars()
.rev()
.take_while(|c| *c == '/')
.count()
.saturating_sub(1);
let s = &s[0..s.len() - trim_end];
if s.is_empty() || omit_slash || begins_with_query_or_hash(s) {
s.into()
} else {
@@ -70,9 +77,10 @@ fn begins_with_query_or_hash(text: &str) -> bool {
}
fn remove_wildcard(text: &str) -> String {
text.split_once('*')
.map(|(prefix, _)| prefix.trim_end_matches('/'))
text.rsplit_once('*')
.map(|(prefix, _)| prefix)
.unwrap_or(text)
.trim_end_matches('/')
.to_string()
}
@@ -83,4 +91,14 @@ mod tests {
fn normalize_query_string_with_opening_slash() {
assert_eq!(normalize("/?foo=bar", false), "?foo=bar");
}
#[test]
fn normalize_retain_trailing_slash() {
assert_eq!(normalize("foo/bar/", false), "/foo/bar/");
}
#[test]
fn normalize_dedup_trailing_slashes() {
assert_eq!(normalize("foo/bar/////", false), "/foo/bar/");
}
}

View File

@@ -1,4 +1,4 @@
use crate::{Loader, Method, SsrMode, StaticData, StaticMode};
use crate::{Loader, Method, SsrMode, StaticData, StaticMode, TrailingSlash};
use leptos::leptos_dom::View;
use std::rc::Rc;
@@ -25,6 +25,8 @@ pub struct RouteDefinition {
pub static_mode: Option<StaticMode>,
/// The data required to fill any dynamic segments in the path during static rendering.
pub static_params: Option<StaticData>,
/// How a trailng slash in `path` should be handled.
pub trailing_slash: Option<TrailingSlash>,
}
impl core::fmt::Debug for RouteDefinition {
@@ -34,6 +36,7 @@ impl core::fmt::Debug for RouteDefinition {
.field("children", &self.children)
.field("ssr_mode", &self.ssr_mode)
.field("static_render", &self.static_mode)
.field("trailing_slash", &self.trailing_slash)
.finish()
}
}

View File

@@ -44,5 +44,14 @@ cfg_if! {
assert_eq!(join_paths("/foo", ":bar/baz"), "/foo/:bar/baz");
assert_eq!(join_paths("", ":bar/baz"), "/:bar/baz");
}
// Additional tests NOT from Solid Router:
#[test]
fn join_paths_for_root() {
assert_eq!(join_paths("", ""), "");
assert_eq!(join_paths("", "/"), "");
assert_eq!(join_paths("/", ""), "");
assert_eq!(join_paths("/", "/"), "");
}
}
}

View File

@@ -0,0 +1,59 @@
//! Some extra tests for Matcher NOT based on SolidJS's tests cases (as in matcher.rs)
use leptos_router::*;
#[test]
fn trailing_slashes_match_exactly() {
let matcher = Matcher::new("/foo/");
assert_matches(&matcher, "/foo/");
assert_no_match(&matcher, "/foo");
let matcher = Matcher::new("/foo/bar/");
assert_matches(&matcher, "/foo/bar/");
assert_no_match(&matcher, "/foo/bar");
let matcher = Matcher::new("/");
assert_matches(&matcher, "/");
assert_matches(&matcher, "");
let matcher = Matcher::new("");
assert_matches(&matcher, "");
// Despite returning a pattern of "", web servers (known: Actix-Web and Axum)
// may send us a path of "/". We should match those at the root:
assert_matches(&matcher, "/");
}
#[cfg(feature = "ssr")]
#[test]
fn trailing_slashes_params_match_exactly() {
let matcher = Matcher::new("/foo/:bar/");
assert_matches(&matcher, "/foo/bar/");
assert_matches(&matcher, "/foo/42/");
assert_matches(&matcher, "/foo/%20/");
assert_no_match(&matcher, "/foo/bar");
assert_no_match(&matcher, "/foo/42");
assert_no_match(&matcher, "/foo/%20");
let m = matcher.test("/foo/asdf/").unwrap();
assert_eq!(m.params, params_map! { "bar" => "asdf" });
}
fn assert_matches(matcher: &Matcher, path: &str) {
assert!(
matches(matcher, path),
"{matcher:?} should match path {path:?}"
);
}
fn assert_no_match(matcher: &Matcher, path: &str) {
assert!(
!matches(matcher, path),
"{matcher:?} should NOT match path {path:?}"
);
}
fn matches(m: &Matcher, loc: &str) -> bool {
m.test(loc).is_some()
}

View File

@@ -7,6 +7,7 @@ license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "RPC for any web framework."
readme = "../README.md"
rust-version.workspace = true
[dependencies]
server_fn_macro_default = { workspace = true }
@@ -27,7 +28,9 @@ once_cell = "1"
actix-web = { version = "4", optional = true }
# axum
axum = { version = "0.7", optional = true, default-features = false, features = ["multipart"] }
axum = { version = "0.7", optional = true, default-features = false, features = [
"multipart",
] }
tower = { version = "0.4", optional = true }
tower-layer = { version = "0.3", optional = true }
@@ -73,8 +76,6 @@ url = "2"
[features]
default = ["json", "cbor"]
form-redirects = []
actix = ["ssr", "dep:actix-web", "dep:send_wrapper"]
axum-no-default = [
"ssr",
"dep:axum",
@@ -83,10 +84,9 @@ axum-no-default = [
"dep:tower",
"dep:tower-layer",
]
axum = [
"axum/default",
"axum-no-default",
]
form-redirects = []
actix = ["ssr", "dep:actix-web", "dep:send_wrapper"]
axum = ["axum/default", "axum-no-default"]
browser = [
"dep:gloo-net",
"dep:js-sys",
@@ -112,7 +112,21 @@ all-features = true
# disables some feature combos for testing in CI
[package.metadata.cargo-all-features]
denylist = ["rustls", "default-tls", "form-redirects"]
denylist = [
"rustls",
"default-tls",
"form-redirects",
"gloo-net",
"js-sys",
"wasm-bindgen",
"web-sys",
"tower",
"tower-layer",
"send_wrapper",
"ciborium",
"hyper",
"inventory",
]
skip_feature_sets = [
[
"actix",
@@ -130,4 +144,48 @@ skip_feature_sets = [
"browser",
"reqwest",
],
[
"default-tls",
"rustls",
],
[
"browser",
"ssr",
],
[
"axum-no-default",
"actix",
],
[
"axum-no-default",
"browser",
],
[
"rkyv",
"json",
],
[
"rkyv",
"cbor",
],
[
"rkyv",
"url",
],
[
"rkyv",
"serde-lite",
],
[
"url",
"json",
],
[
"url",
"cbor",
],
[
"url",
"serde-lite",
],
]

View File

@@ -92,7 +92,7 @@
//! 6. The server integration applies any middleware from [`ServerFn::middlewares`] and responds to the request.
//! 7. [`FromRes`]: The client deserializes the response back into the [`ServerFn::Output`] type.
//!
//! [server]: <https://docs.rs/server_fn/latest/server_fn/attr.server.html>
//! [server]: ../leptos/attr.server.html
//! [`serde_qs`]: <https://docs.rs/serde_qs/latest/serde_qs/>
//! [`cbor`]: <https://docs.rs/cbor/latest/cbor/>
@@ -472,7 +472,7 @@ pub mod axum {
/// Explicitly register a server function. This is only necessary if you are
/// running the server in a WASM environment (or a rare environment that the
/// `inventory`).
/// `inventory` crate won't work in.).
pub fn register_explicit<T>()
where
T: ServerFn<
@@ -556,7 +556,7 @@ pub mod actix {
/// Explicitly register a server function. This is only necessary if you are
/// running the server in a WASM environment (or a rare environment that the
/// `inventory`).
/// `inventory` crate won't work in.).
pub fn register_explicit<T>()
where
T: ServerFn<

View File

@@ -25,9 +25,9 @@ pub fn set_redirect_hook(
REDIRECT_HOOK.set(Box::new(hook))
}
/// Calls the hook that has been set by [`set_redirect_hook`] to redirect to `path`.
pub fn call_redirect_hook(path: &str) {
/// Calls the hook that has been set by [`set_redirect_hook`] to redirect to `loc`.
pub fn call_redirect_hook(loc: &str) {
if let Some(hook) = REDIRECT_HOOK.get() {
hook(path)
hook(loc)
}
}

View File

@@ -119,6 +119,7 @@ pub fn server_macro_impl(
res_ty,
client,
custom_wrapper,
impl_from,
} = args;
let prefix = prefix.unwrap_or_else(|| Literal::string(default_path));
let fn_path = fn_path.unwrap_or_else(|| Literal::string(""));
@@ -206,8 +207,11 @@ pub fn server_macro_impl(
FnArg::Receiver(_) => None,
FnArg::Typed(t) => Some((&t.pat, &t.ty)),
});
let from_impl =
(body.inputs.len() == 1 && first_field.is_some()).then(|| {
let impl_from = impl_from.map(|v| v.value).unwrap_or(true);
let from_impl = (body.inputs.len() == 1
&& first_field.is_some()
&& impl_from)
.then(|| {
let field = first_field.unwrap();
let (name, ty) = field;
quote! {
@@ -434,7 +438,7 @@ pub fn server_macro_impl(
quote! {
#server_fn_path::request::BrowserMockReq
}
} else if cfg!(feature = "axum-no-default") {
} else if cfg!(feature = "axum") {
quote! {
#server_fn_path::axum_export::http::Request<#server_fn_path::axum_export::body::Body>
}
@@ -458,7 +462,7 @@ pub fn server_macro_impl(
quote! {
#server_fn_path::response::BrowserMockRes
}
} else if cfg!(feature = "axum-no-default") {
} else if cfg!(feature = "axum") {
quote! {
#server_fn_path::axum_export::http::Response<#server_fn_path::axum_export::body::Body>
}
@@ -638,7 +642,7 @@ fn err_type(return_ty: &Type) -> Result<Option<&GenericArgument>> {
{
if let Some(segment) = pat.path.segments.last() {
if segment.ident == "ServerFnError" {
let args = &pat.path.segments[0].arguments;
let args = &segment.arguments;
match args {
// Result<T, ServerFnError>
PathArguments::None => return Ok(None),
@@ -676,6 +680,7 @@ struct ServerFnArgs {
client: Option<Type>,
custom_wrapper: Option<Path>,
builtin_encoding: bool,
impl_from: Option<LitBool>,
}
impl Parse for ServerFnArgs {
@@ -693,6 +698,7 @@ impl Parse for ServerFnArgs {
let mut res_ty: Option<Type> = None;
let mut client: Option<Type> = None;
let mut custom_wrapper: Option<Path> = None;
let mut impl_from: Option<LitBool> = None;
let mut use_key_and_value = false;
let mut arg_pos = 0;
@@ -800,6 +806,14 @@ impl Parse for ServerFnArgs {
));
}
custom_wrapper = Some(stream.parse()?);
} else if key == "impl_from" {
if impl_from.is_some() {
return Err(syn::Error::new(
key.span(),
"keyword argument repeated: `impl_from`",
));
}
impl_from = Some(stream.parse()?);
} else {
return Err(lookahead.error());
}
@@ -895,6 +909,7 @@ impl Parse for ServerFnArgs {
res_ty,
client,
custom_wrapper,
impl_from,
})
}
}