diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml
index db27c709143..9e405245a3c 100644
--- a/.github/workflows/main-checks.yml
+++ b/.github/workflows/main-checks.yml
@@ -89,7 +89,6 @@ jobs:
args: --doc --features doc_test --features wasm_test
-
integration_tests:
name: Integration Tests on ${{ matrix.toolchain }}
runs-on: ubuntu-latest
@@ -156,9 +155,39 @@ jobs:
uses: actions-rs/cargo@v1
with:
command: test
- args: --all-targets --workspace --exclude yew --exclude website-test
+ args: --all-targets --workspace --exclude website-test
+ ssr_tests:
+ name: SSR Tests on ${{ matrix.toolchain }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ toolchain:
+ # anyway to dynamically grep the MSRV from Cargo.toml?
+ - 1.56.0 # MSRV
+ - stable
+ - nightly
+
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+
+ - uses: actions-rs/toolchain@v1
+ with:
+ toolchain: ${{ matrix.toolchain }}
+ override: true
+ profile: minimal
+
+ - uses: Swatinem/rust-cache@v1
+
+ - name: Run tests
+ uses: actions-rs/cargo@v1
+ with:
+ command: test
+ args: --workspace ssr_tests --features ssr
+
test-lints:
name: Test lints on nightly
@@ -180,4 +209,3 @@ jobs:
with:
command: test
args: -p yew-macro test_html_lints --features lints
-
diff --git a/.github/workflows/publish-examples.yml b/.github/workflows/publish-examples.yml
index 69168f5269b..61aec065716 100644
--- a/.github/workflows/publish-examples.yml
+++ b/.github/workflows/publish-examples.yml
@@ -58,6 +58,11 @@ jobs:
continue
fi
+ # ssr does not need trunk
+ if [[ "$example" == "ssr_router" ]]; then
+ continue
+ fi
+
echo "building: $example"
(
cd "$path"
diff --git a/Cargo.toml b/Cargo.toml
index a8a25de90ac..db08be82dff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,7 @@ members = [
"examples/dyn_create_destroy_apps",
"examples/file_upload",
"examples/function_memory_game",
+ "examples/function_router",
"examples/function_todomvc",
"examples/futures",
"examples/game_of_life",
@@ -28,6 +29,7 @@ members = [
"examples/portals",
"examples/router",
"examples/simple_ssr",
+ "examples/ssr_router",
"examples/timer",
"examples/todomvc",
"examples/two_apps",
diff --git a/Makefile.toml b/Makefile.toml
index 3e62f00fcb8..3a5fec2f29d 100644
--- a/Makefile.toml
+++ b/Makefile.toml
@@ -82,7 +82,7 @@ dependencies = ["test"]
[tasks.test]
private = true
command = "cargo"
-args = ["test", "--all-targets", "--workspace", "--exclude", "website-test"]
+args = ["test", "--all-targets", "--workspace", "--exclude", "website-test", "--exclude", "changelog"]
[tasks.doc-test-flow]
private = true
diff --git a/examples/function_router/Cargo.toml b/examples/function_router/Cargo.toml
new file mode 100644
index 00000000000..0593e73ca34
--- /dev/null
+++ b/examples/function_router/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "function_router"
+version = "0.1.0"
+edition = "2021"
+license = "MIT OR Apache-2.0"
+
+[dependencies]
+lipsum = "0.8"
+log = "0.4"
+rand = { version = "0.8", features = ["small_rng"] }
+yew = { path = "../../packages/yew" }
+yew-router = { path = "../../packages/yew-router" }
+serde = { version = "1.0", features = ["derive"] }
+lazy_static = "1.4.0"
+gloo-timers = "0.2"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+getrandom = { version = "0.2", features = ["js"] }
+instant = { version = "0.1", features = ["wasm-bindgen"] }
+wasm-logger = "0.2"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+instant = { version = "0.1" }
diff --git a/examples/function_router/README.md b/examples/function_router/README.md
new file mode 100644
index 00000000000..2a78b7ba732
--- /dev/null
+++ b/examples/function_router/README.md
@@ -0,0 +1,49 @@
+# Function Router Example
+
+This is identical to the router example, but written in function
+components.
+
+[](https://examples.yew.rs/function_router)
+
+A blog all about yew.
+The best way to figure out what this example is about is to just open it up.
+It's mobile friendly too!
+
+## Running
+
+While not strictly necessary, this example should be built in release mode:
+
+```bash
+trunk serve --release
+```
+
+Content generation can take up quite a bit of time in debug builds.
+
+## Concepts
+
+This example involves many different parts, here are just the Yew specific things:
+
+- Uses [`yew-router`] to render and switch between multiple pages.
+
+The example automatically adapts to the `--public-url` value passed to Trunk.
+This allows it to be hosted on any path, not just at the root.
+For example, our demo is hosted at [/router](https://examples.yew.rs/router).
+
+This is achieved by adding ` ` to the [index.html](index.html) file.
+Trunk rewrites this tag to contain the value passed to `--public-url` which can then be retrieved at runtime.
+Take a look at [`Route`](src/main.rs) for the implementation.
+
+## Improvements
+
+- Use a special image component which shows a progress bar until the image is loaded.
+- Scroll back to the top after switching route
+- Run content generation in a dedicated web worker
+- Use longer Markov chains to achieve more coherent results
+- Make images deterministic (the same seed should produce the same images)
+- Show posts by the author on their page
+ (this is currently impossible because we need to find post seeds which in turn generate the author's seed)
+- Show other posts at the end of a post ("continue reading")
+- Home (`/`) should include links to the post list and the author introduction
+- Detect sub-path from `--public-url` value passed to Trunk. See: thedodd/trunk#51
+
+[`yew-router`]: https://docs.rs/yew-router/latest/yew_router/
diff --git a/examples/function_router/data/keywords.txt b/examples/function_router/data/keywords.txt
new file mode 100644
index 00000000000..874d1e2c302
--- /dev/null
+++ b/examples/function_router/data/keywords.txt
@@ -0,0 +1,34 @@
+allergenics
+archaeology
+austria
+berries
+birds
+color
+conservation
+cosmology
+culture
+europe
+evergreens
+fleshy
+france
+guides
+horticulture
+ireland
+landscaping
+medicine
+music
+poison
+religion
+rome
+rust
+scotland
+seeds
+spain
+taxonomy
+toxics
+tradition
+trees
+wasm
+wood
+woodworking
+yew
diff --git a/examples/function_router/data/syllables.txt b/examples/function_router/data/syllables.txt
new file mode 100644
index 00000000000..c6e97aabb8c
--- /dev/null
+++ b/examples/function_router/data/syllables.txt
@@ -0,0 +1,20 @@
+ald
+ber
+fe
+ger
+jo
+jus
+kas
+lix
+lu
+mon
+mour
+nas
+ridge
+ry
+si
+star
+tey
+tim
+tin
+yew
diff --git a/examples/function_router/data/yew.txt b/examples/function_router/data/yew.txt
new file mode 100644
index 00000000000..8e3a2d9f4f0
--- /dev/null
+++ b/examples/function_router/data/yew.txt
@@ -0,0 +1,317 @@
+Taxonomy and naming
+
+The word yew is from Proto-Germanic, possibly originally a loanword from Gaulish.
+In German it is known as Eibe. Baccata is Latin for bearing berries.
+The word yew as it was originally used seems to refer to the color brown.
+The yew was known to Theophrastus, who noted its preference for mountain coolness and shade,
+its evergreen character and its slow growth.
+
+Most Romance languages, with the notable exception of French,
+kept a version of the Latin word taxus from the same root as toxic.
+In Slavic languages, the same root is preserved.
+
+In Iran, the tree is known as sorkhdār.
+
+The common yew was one of the many species first described by Linnaeus.
+It is one of around 30 conifer species in seven genera in the family Taxaceae, which is placed in the order Pinales.
+
+
+Description
+
+It is a small to medium-sized evergreen tree, growing 10 to 20 metres tall, with a trunk up to 2 metres in diameter.
+The bark is thin, scaly brown, coming off in small flakes aligned with the stem.
+The leaves are flat, dark green, 1 to 4 centimetres long and 2 to 3 millimetres broad, arranged spirally on the stem,
+but with the leaf bases twisted to align the leaves in two flat rows either side of the stem,
+except on erect leading shoots where the spiral arrangement is more obvious.
+The leaves are poisonous.
+
+The seed cones are modified, each cone containing a single seed, which is 4 to 7 millimetres long,
+and partly surrounded by a fleshy scale which develops into a soft, bright red berry-like structure called an aril.
+The aril is 8 to 15 millimetres long and wide and open at the end.
+The arils mature 6 to 9 months after pollination, and with the seed contained,
+are eaten by thrushes, waxwings and other birds, which disperse the hard seeds undamaged in their droppings.
+Maturation of the arils is spread over 2 to 3 months, increasing the chances of successful seed dispersal.
+The seeds themselves are poisonous and bitter, but are opened and eaten by some bird species including hawfinches,
+greenfinches and great tits.
+The aril is not poisonous, it is gelatinous and very sweet tasting. The male cones are globose,
+3–6 millimetres in diameter, and shed their pollen in early spring.
+The yew is mostly dioecious, but occasional individuals can be variably monoecious, or change sex with time.
+
+
+Longevity
+
+Taxus baccata can reach 400 to 600 years of age.
+Some specimens live longer but the age of yews is often overestimated.
+Ten yews in Britain are believed to predate the 10th century.
+The potential age of yews is impossible to determine accurately and is subject to much dispute.
+There is rarely any wood as old as the entire tree, while the boughs themselves often become hollow with age,
+making ring counts impossible.
+Evidence based on growth rates and archaeological work of surrounding structures suggests the oldest yews,
+such as the Fortingall Yew in Perthshire, Scotland, may be in the range of 2,000 years,
+placing them among the oldest plants in Europe.
+One characteristic contributing to yew's longevity is that it is able to split under the weight of advanced growth
+without succumbing to disease in the fracture, as do most other trees. Another is its ability to give rise to new
+epicormic and basal shoots from cut surfaces and low on its trunk, even at an old age.
+
+
+Significant trees
+
+The Fortingall Yew in Perthshire, Scotland,
+has the largest recorded trunk girth in Britain and experts estimate it to be 2,000 to 3,000 years old,
+although it may be a remnant of a post-Roman Christian site and around 1,500 years old.
+The Llangernyw Yew in Clwyd, Wales, can be found at an early saint site and is about 1,500 years old.
+Other well known yews include the Ankerwycke Yew, the Balderschwang Yew, the Caesarsboom, the Florence Court Yew,
+and the Borrowdale Fraternal Four, of which poet William Wordsworth wrote.
+The Kingley Vale National Nature Reserve in West Sussex has one of Europe's largest yew woodlands.
+
+The oldest specimen in Spain is located in Bermiego, Asturias. It is known as Teixu l'Iglesia in the Asturian language.
+It stands 15m tall with a trunk diameter of 7m and a crown diameter of 15m.
+It was declared a Natural Monument on April 27,
+1995 by the Asturian Government and is protected by the Plan of Natural Resources.
+
+A unique forest formed by Taxus baccata and European box lies within the city of Sochi, in the Western Caucasus.
+
+The oldest Irish Yew, the Florence Court Yew, still stands in the grounds of Florence Court estate in County Fermanagh,
+Northern Ireland.
+The Irish Yew has become ubiquitous in cemeteries across the world and it is believed that all known examples are from
+cuttings from this tree.
+
+
+Toxicity
+
+The entire yew bush, except the aril, is poisonous.
+It is toxic due to a group of chemicals called taxine alkaloids.
+Their cardiotoxicity is well known and act via calcium and sodium channel antagonism, causing an increase in
+cytoplasmic calcium currents of the myocardial cells.
+The seeds contain the highest concentrations of these alkaloids. If any leaves or seeds of the plant are ingested,
+urgent medical advice is recommended as well as observation for at least 6 hours after the point of ingestion.
+The most cardiotoxic taxine is Taxine B followed by Taxine A.
+Taxine B also happens to be the most common alkaloid in the Taxus species.
+
+Yew poisonings are relatively common in both domestic and wild animals who consume the plant accidentally,
+resulting in countless fatalities in livestock.
+The taxine alkaloids are absorbed quickly from the intestine and in high enough quantities can cause death due to
+cardiac arrest or respiratory failure.
+Taxines are also absorbed efficiently via the skin and Taxus species should thus be handled with care and preferably
+with gloves.
+Taxus baccata leaves contain approximately 5mg of taxines per 1g of leaves.
+
+The estimated lethal dose of taxine alkaloids is approximately 3.0mg/kg body weight for humans.
+The lethal dose for an adult is reported to be 50g of yew needles.
+Patients who ingest a lethal dose frequently die due to cardiogenic shock, in spite of resuscitation efforts.
+There are currently no known antidotes for yew poisoning,
+but drugs such as atropine have been used to treat the symptoms.
+Taxine remains in the plant all year, with maximal concentrations appearing during the winter.
+Dried yew plant material retains its toxicity for several months and even increases its toxicity as the water is removed.
+Fallen leaves should therefore also be considered toxic.
+Poisoning usually occurs when leaves of yew trees are eaten,
+but in at least one case a victim inhaled sawdust from a yew tree.
+
+It is difficult to measure taxine alkaloids and this is a major reason as to why different studies show different results.
+
+Several studies have found taxine LD50 values under 20mg/kg in mice and rats.
+
+Male and monoecious yews in this genus release toxic pollen, which can cause the mild symptoms.
+The pollen is also a trigger for asthma.
+These pollen grains are only 15 microns in size, and can easily pass through most window screens.
+
+
+Allergenic potential
+
+Yews in this genus are primarily separate-sexed, and males are extremely allergenic,
+with an OPALS allergy scale rating of 10 out of 10.
+Completely female yews have an OPALS rating of 1, and are considered allergy-fighting.
+Male yews bloom and release abundant amounts of pollen in the spring;
+completely female yews only trap pollen while producing none.
+
+
+Uses and traditions
+
+In the ancient Celtic world, the yew tree had extraordinary importance; a passage by Caesar narrates that Cativolcus,
+chief of the Eburones poisoned himself with yew rather than submit to Rome.
+Similarly, Florus notes that when the Cantabrians were under siege by the legate Gaius Furnius in 22 BC,
+most of them took their lives either by the sword, by fire, or by a poison extracted ex arboribus taxeis, that is,
+from the yew tree.
+In a similar way, Orosius notes that when the Astures were besieged at Mons Medullius,
+they preferred to die by their own swords or by the yew tree poison rather than surrender.
+
+The word York is derived from the Brittonic name Eburākon,
+a combination of eburos "yew-tree" and a suffix of appurtenance meaning either "place of the yew trees";
+or alternatively, "the settlement of Eburos".
+
+The name Eboracum became the Anglian Eoforwic in the 7th century.
+When the Danish army conquered the city in 866, its name became Jórvík.
+
+The Old French and Norman name of the city following the Norman Conquest was recorded as Everwic in works such as
+Wace's Roman de Rou.
+Jórvík, meanwhile, gradually reduced to York in the centuries after the Conquest,
+moving from the Middle English Yerk in the 14th century through Yourke in the 16th century to Yarke in the 17th century.
+The form York was first recorded in the 13th century. Many company and place names, such as the Ebor race meeting,
+refer to the Latinised Brittonic, Roman name.
+
+The 12th‑century chronicler Geoffrey of Monmouth, in his fictional account of the prehistoric kings of Britain,
+Historia Regum Britanniae, suggests the name derives from that of a pre-Roman city founded by the legendary king Ebraucus.
+
+The Archbishop of York uses Ebor as his surname in his signature.
+
+The area of Ydre in the South Swedish highlands is interpreted to mean place of yews.
+Two localities in particular, Idhult and Idebo, appear to be further associated with yews.
+
+
+Religion
+
+The yew is traditionally and regularly found in churchyards in England, Wales, Scotland, Ireland and Northern France.
+Some examples can be found in La Haye-de-Routot or La Lande-Patry.
+It is said up to 40 people could stand inside one of the La-Haye-de-Routot yew trees,
+and the Le Ménil-Ciboult yew is probably the largest at 13m diameter.
+Yews may grow to become exceptionally large and may live to be over 2,000 years old.
+Sometimes monks planted yews in the middle of their cloister, as at Muckross Abbey or abbaye de Jumièges.
+Some ancient yew trees are located at St. Mary the Virgin Church, Overton-on-Dee in Wales.
+
+In Asturian tradition and culture, the yew tree was considered to be linked with the land, people,
+ancestors and ancient religion. It was tradition on All Saints' Day to bring a branch of a yew tree to the tombs of
+those who had died recently so they would be guided in their return to the Land of Shadows.
+The yew tree has been found near chapels,
+churches and cemeteries since ancient times as a symbol of the transcendence of death.
+They are often found in the main squares of villages where people celebrated the open councils that served as a way of
+general assembly to rule village affairs.
+
+It has been suggested that the sacred tree at the Temple at Uppsala was an ancient yew tree.
+The Christian church commonly found it expedient to take over existing pre-Christian sacred sites for churches.
+It has also been suggested that yews were planted at religious sites as their long life was suggestive of eternity,
+or because, being toxic when ingested, they were seen as trees of death.
+Another suggested explanation is that yews were planted to discourage farmers and drovers from letting animals wander
+onto the burial grounds, the poisonous foliage being the disincentive.
+A further possible reason is that fronds and branches of yew were often used as a substitute for palms on Palm Sunday.
+
+Some yew trees were actually native to the sites before the churches were built.
+King Edward I of England ordered yew trees to be planted in churchyards to offer some protection to the buildings.
+Yews are poisonous so by planting them in the churchyards cattle that were not allowed to graze on hallowed
+ground were safe from eating yew. Yew branches touching the ground take root and sprout again;
+this became a symbol of death, rebirth and therefore immortality.
+
+In interpretations of Norse cosmology, the tree Yggdrasil has traditionally been interpreted as a giant ash tree.
+Some scholars now believe errors were made in past interpretations of the ancient writings,
+and that the tree is most likely a European yew.
+
+In the Crann Ogham—the variation on the ancient Irish Ogham alphabet which consists of a list of trees—yew
+is the last in the main list of 20 trees, primarily symbolizing death.
+There are stories of people who have committed suicide by ingesting the foliage.
+As the ancient Celts also believed in the transmigration of the soul,
+there is in some cases a secondary meaning of the eternal soul that survives death to be reborn in a new form.
+
+
+Medical
+
+Certain compounds found in the bark of yew trees were discovered by Wall and Wani in 1967 to have efficacy as
+anti-cancer agents.
+The precursors of the chemotherapy drug paclitaxel were later shown to be synthesized easily from extracts
+of the leaves of European yew, which is a much more renewable source than the bark of the Pacific yew from which
+they were initially isolated.
+This ended a point of conflict in the early 1990s; many environmentalists,
+including Al Gore, had opposed the destructive harvesting of Pacific yew for paclitaxel cancer treatments.
+Docetaxel can then be obtained by semi-synthetic conversion from the precursors.
+
+
+Woodworking and longbows
+
+Wood from the yew is classified as a closed-pore softwood, similar to cedar and pine.
+Easy to work, yew is among the hardest of the softwoods; yet it possesses a remarkable elasticity,
+making it ideal for products that require springiness, such as bows.
+Due to all parts of the yew and its volatile oils being poisonous and cardiotoxic,
+a mask should be worn if one comes in contact with sawdust from the wood.
+
+One of the world's oldest surviving wooden artifacts is a Clactonian yew spear head, found in 1911 at Clacton-on-Sea,
+in Essex, UK. Known as the Clacton Spear, it is estimated to be over 400,000 years old.
+
+Yew is also associated with Wales and England because of the longbow,
+an early weapon of war developed in northern Europe,
+and as the English longbow the basis for a medieval tactical system.
+The oldest surviving yew longbow was found at Rotten Bottom in Dumfries and Galloway, Scotland.
+It has been given a calibrated radiocarbon date of 4040 BC to 3640 BC and is on display in the National Museum of
+Scotland. Yew is the wood of choice for longbow making;
+the heartwood is always on the inside of the bow with the sapwood on the outside.
+This makes most efficient use of their properties as heartwood is best in compression whilst
+sapwood is superior in tension.
+However, much yew is knotty and twisted, and therefore unsuitable for bowmaking;
+most trunks do not give good staves and even in a good trunk much wood has to be discarded.
+
+There was a tradition of planting yew trees in churchyards throughout Britain and Ireland, among other reasons,
+as a resource for bows.
+Ardchattan Priory whose yew trees, according to other accounts,
+were inspected by Robert the Bruce and cut to make at least some of the longbows used at the Battle of Bannockburn.
+
+The trade of yew wood to England for longbows was so robust that it depleted the stocks of good-quality,
+mature yew over a vast area.
+The first documented import of yew bowstaves to England was in 1294.
+In 1423 the Polish king commanded protection of yews in order to cut exports,
+facing nearly complete destruction of local yew stock. In 1470 compulsory archery practice was renewed, and hazel, ash,
+and laburnum were specifically allowed for practice bows.
+Supplies still proved insufficient, until by the Statute of Westminster in 1472,
+every ship coming to an English port had to bring four bowstaves for every tun.
+Richard III of England increased this to ten for every tun. This stimulated a vast network of extraction and supply,
+which formed part of royal monopolies in southern Germany and Austria.
+In 1483, the price of bowstaves rose from two to eight pounds per hundred,
+and in 1510 the Venetians would only sell a hundred for sixteen pounds.
+In 1507 the Holy Roman Emperor asked the Duke of Bavaria to stop cutting yew, but the trade was profitable,
+and in 1532 the royal monopoly was granted for the usual quantity if there are that many.
+In 1562, the Bavarian government sent a long plea to the Holy Roman Emperor asking him to stop the cutting of yew,
+and outlining the damage done to the forests by its selective extraction,
+which broke the canopy and allowed wind to destroy neighbouring trees. In 1568, despite a request from Saxony,
+no royal monopoly was granted because there was no yew to cut,
+and the next year Bavaria and Austria similarly failed to produce enough yew to justify a royal monopoly.
+Forestry records in this area in the 17th century do not mention yew, and it seems that no mature trees were to be had.
+The English tried to obtain supplies from the Baltic, but at this period bows were being replaced by guns in any case.
+
+
+Horticulture
+
+Today European yew is widely used in landscaping and ornamental horticulture.
+Due to its dense, dark green, mature foliage, and its tolerance of even very severe pruning,
+it is used especially for formal hedges and topiary.
+Its relatively slow growth rate means that in such situations it needs to be clipped only once per year.
+
+Well over 200 cultivars of T. baccata have been named. The most popular of these are the Irish yew,
+a fastigiate cultivar of the European yew selected from two trees found growing in Ireland,
+and the several cultivars with yellow leaves, collectively known as golden yew. In some locations,
+when hemmed in by buildings or other trees,
+an Irish yew can reach 20 feet in height without exceeding 2 feet in diameter at its thickest point,
+although with age many Irish yews assume a fat cigar shape rather than being truly columnar.
+
+European yew will tolerate growing in a wide range of soils and situations, including shallow chalk soils and shade,
+although in deep shade its foliage may be less dense.
+However it cannot tolerate waterlogging,
+and in poorly-draining situations is liable to succumb to the root-rotting pathogen Phytophthora cinnamomi.
+
+In Europe, Taxus baccata grows naturally north to Molde in southern Norway, but it is used in gardens further north.
+It is also popular as a bonsai in many parts of Europe and makes a handsome small- to large-sized bonsai.
+
+
+Privies
+
+In England, yew has historically been sometimes associated with privies,
+possibly because the smell of the plant keeps insects away.
+
+
+Musical instruments
+
+The late Robert Lundberg, a noted luthier who performed extensive research on historical lute-making methodology,
+states in his 2002 book Historical Lute Construction that yew was historically a prized wood for lute construction.
+European legislation establishing use limits and requirements for yew limited supplies available to luthiers,
+but it was apparently as prized among medieval, renaissance,
+and baroque lute builders as Brazilian rosewood is among contemporary guitar-makers for its quality of sound and beauty.
+
+
+Conservation
+
+Clippings from ancient specimens in the UK, including the Fortingall Yew,
+were taken to the Royal Botanic Gardens in Edinburgh to form a mile-long hedge.
+The purpose of this project is to maintain the DNA of Taxus baccata.
+The species is threatened by felling, partly due to rising demand from pharmaceutical companies, and disease.
+
+Another conservation programme was run in Catalonia in the early 2010s, by the Forest Sciences Centre of Catalonia,
+in order to protect genetically endemic yew populations, and preserve them from overgrazing and forest fires.
+In the framework of this programme, the 4th International Yew Conference was organised in the Poblet Monastery in 2014,
+which proceedings are available.
+
+There has also been a conservation programme in northern Portugal and Northern Spain.
diff --git a/examples/function_router/index.html b/examples/function_router/index.html
new file mode 100644
index 00000000000..d7101222d8b
--- /dev/null
+++ b/examples/function_router/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ Yew • Function Router
+
+
+
+
+
+
+
diff --git a/examples/function_router/index.scss b/examples/function_router/index.scss
new file mode 100644
index 00000000000..fafc9be01bc
--- /dev/null
+++ b/examples/function_router/index.scss
@@ -0,0 +1,27 @@
+.hero {
+ &.has-background {
+ position: relative;
+ overflow: hidden;
+ }
+
+ &-background {
+ position: absolute;
+ object-fit: cover;
+ object-position: bottom;
+ width: 100%;
+ height: 100%;
+
+ &.is-transparent {
+ opacity: 0.3;
+ }
+ }
+}
+
+.burger {
+ background-color: transparent;
+ border: none;
+}
+
+.navbar-brand {
+ align-items: center;
+}
diff --git a/examples/function_router/src/app.rs b/examples/function_router/src/app.rs
new file mode 100644
index 00000000000..ce581a59bf4
--- /dev/null
+++ b/examples/function_router/src/app.rs
@@ -0,0 +1,118 @@
+use yew::prelude::*;
+use yew_router::prelude::*;
+
+use crate::components::nav::Nav;
+use crate::pages::{
+ author::Author, author_list::AuthorList, home::Home, page_not_found::PageNotFound, post::Post,
+ post_list::PostList,
+};
+
+#[derive(Routable, PartialEq, Clone, Debug)]
+pub enum Route {
+ #[at("/posts/:id")]
+ Post { id: u32 },
+ #[at("/posts")]
+ Posts,
+ #[at("/authors/:id")]
+ Author { id: u32 },
+ #[at("/authors")]
+ Authors,
+ #[at("/")]
+ Home,
+ #[not_found]
+ #[at("/404")]
+ NotFound,
+}
+
+#[function_component]
+pub fn App() -> Html {
+ html! {
+
+
+
+
+ render={Switch::render(switch)} />
+
+
+
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+mod arch_native {
+ use super::*;
+
+ use yew::virtual_dom::AttrValue;
+ use yew_router::history::{AnyHistory, History, MemoryHistory};
+
+ use std::collections::HashMap;
+
+ #[derive(Properties, PartialEq, Debug)]
+ pub struct ServerAppProps {
+ pub url: AttrValue,
+ pub queries: HashMap,
+ }
+
+ #[function_component]
+ pub fn ServerApp(props: &ServerAppProps) -> Html {
+ let history = AnyHistory::from(MemoryHistory::new());
+ history
+ .push_with_query(&*props.url, &props.queries)
+ .unwrap();
+
+ html! {
+
+
+
+
+ render={Switch::render(switch)} />
+
+
+
+ }
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+pub use arch_native::*;
+
+fn switch(routes: &Route) -> Html {
+ match routes.clone() {
+ Route::Post { id } => {
+ html! { }
+ }
+ Route::Posts => {
+ html! { }
+ }
+ Route::Author { id } => {
+ html! { }
+ }
+ Route::Authors => {
+ html! { }
+ }
+ Route::Home => {
+ html! { }
+ }
+ Route::NotFound => {
+ html! { }
+ }
+ }
+}
diff --git a/examples/function_router/src/components/author_card.rs b/examples/function_router/src/components/author_card.rs
new file mode 100644
index 00000000000..98561d5e1cf
--- /dev/null
+++ b/examples/function_router/src/components/author_card.rs
@@ -0,0 +1,75 @@
+use std::rc::Rc;
+
+use crate::{content::Author, generator::Generated, Route};
+use yew::prelude::*;
+use yew_router::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Properties)]
+pub struct Props {
+ pub seed: u32,
+}
+
+#[derive(PartialEq, Debug)]
+pub struct AuthorState {
+ pub inner: Author,
+}
+
+impl Reducible for AuthorState {
+ type Action = u32;
+
+ fn reduce(self: Rc, action: u32) -> Rc {
+ Self {
+ inner: Author::generate_from_seed(action),
+ }
+ .into()
+ }
+}
+
+#[function_component]
+pub fn AuthorCard(props: &Props) -> Html {
+ let seed = props.seed;
+
+ let author = use_reducer_eq(|| AuthorState {
+ inner: Author::generate_from_seed(seed),
+ });
+
+ {
+ let author_dispatcher = author.dispatcher();
+ use_effect_with_deps(
+ move |seed| {
+ author_dispatcher.dispatch(*seed);
+
+ || {}
+ },
+ seed,
+ );
+ }
+
+ let author = &author.inner;
+
+ html! {
+
+ }
+}
diff --git a/examples/function_router/src/components/mod.rs b/examples/function_router/src/components/mod.rs
new file mode 100644
index 00000000000..fd45287c87c
--- /dev/null
+++ b/examples/function_router/src/components/mod.rs
@@ -0,0 +1,5 @@
+pub mod author_card;
+pub mod nav;
+pub mod pagination;
+pub mod post_card;
+pub mod progress_delay;
diff --git a/examples/function_router/src/components/nav.rs b/examples/function_router/src/components/nav.rs
new file mode 100644
index 00000000000..3c3e7068288
--- /dev/null
+++ b/examples/function_router/src/components/nav.rs
@@ -0,0 +1,57 @@
+use yew::prelude::*;
+use yew_router::prelude::*;
+
+use crate::Route;
+
+#[function_component]
+pub fn Nav() -> Html {
+ let navbar_active = use_state_eq(|| false);
+
+ let toggle_navbar = {
+ let navbar_active = navbar_active.clone();
+
+ Callback::from(move |_| {
+ navbar_active.set(!*navbar_active);
+ })
+ };
+
+ let active_class = if !*navbar_active { "is-active" } else { "" };
+
+ html! {
+
+
+
{ "Yew Blog" }
+
+
+
+
+
+
+
+
+
+ }
+}
diff --git a/examples/function_router/src/components/pagination.rs b/examples/function_router/src/components/pagination.rs
new file mode 100644
index 00000000000..5f859c8df55
--- /dev/null
+++ b/examples/function_router/src/components/pagination.rs
@@ -0,0 +1,157 @@
+use serde::Deserialize;
+use serde::Serialize;
+use std::ops::Range;
+use yew::prelude::*;
+use yew_router::prelude::*;
+
+use crate::Route;
+
+const ELLIPSIS: &str = "\u{02026}";
+
+#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
+pub struct PageQuery {
+ pub page: u32,
+}
+
+#[derive(Clone, Debug, PartialEq, Properties)]
+pub struct Props {
+ pub page: u32,
+ pub total_pages: u32,
+ pub route_to_page: Route,
+}
+
+#[function_component]
+pub fn RelNavButtons(props: &Props) -> Html {
+ let Props {
+ page,
+ total_pages,
+ route_to_page: to,
+ } = props.clone();
+
+ html! {
+ <>
+
+ classes={classes!("pagination-previous")}
+ disabled={page==1}
+ query={Some(PageQuery{page: page - 1})}
+ to={to.clone()}
+ >
+ { "Previous" }
+ >
+
+ classes={classes!("pagination-next")}
+ disabled={page==total_pages}
+ query={Some(PageQuery{page: page + 1})}
+ {to}
+ >
+ { "Next page" }
+ >
+ >
+ }
+}
+
+#[derive(Properties, Clone, Debug, PartialEq)]
+pub struct RenderLinksProps {
+ range: Range,
+ len: usize,
+ max_links: usize,
+ props: Props,
+}
+
+#[function_component]
+pub fn RenderLinks(props: &RenderLinksProps) -> Html {
+ let RenderLinksProps {
+ range,
+ len,
+ max_links,
+ props,
+ } = props.clone();
+
+ let mut range = range;
+
+ if len > max_links {
+ let last_link =
+ html! { };
+ // remove 1 for the ellipsis and 1 for the last link
+ let links = range
+ .take(max_links - 2)
+ .map(|page| html! { });
+ html! {
+ <>
+ { for links }
+
+ { last_link }
+ >
+ }
+ } else {
+ html! { for range.map(|page| html! { }) }
+ }
+}
+
+#[derive(Properties, Clone, Debug, PartialEq)]
+pub struct RenderLinkProps {
+ to_page: u32,
+ props: Props,
+}
+
+#[function_component]
+pub fn RenderLink(props: &RenderLinkProps) -> Html {
+ let RenderLinkProps { to_page, props } = props.clone();
+
+ let Props {
+ page,
+ route_to_page,
+ ..
+ } = props;
+
+ let is_current_class = if to_page == page { "is-current" } else { "" };
+
+ html! {
+
+
+ classes={classes!("pagination-link", is_current_class)}
+ to={route_to_page}
+ query={Some(PageQuery{page: to_page})}
+ >
+ { to_page }
+ >
+
+ }
+}
+
+#[function_component]
+pub fn Links(props: &Props) -> Html {
+ const LINKS_PER_SIDE: usize = 3;
+
+ let Props {
+ page, total_pages, ..
+ } = *props;
+
+ let pages_prev = page.checked_sub(1).unwrap_or_default() as usize;
+ let pages_next = (total_pages - page) as usize;
+
+ let links_left = LINKS_PER_SIDE.min(pages_prev)
+ // if there are less than `LINKS_PER_SIDE` to the right, we add some more on the left.
+ + LINKS_PER_SIDE.checked_sub(pages_next).unwrap_or_default();
+ let links_right = 2 * LINKS_PER_SIDE - links_left;
+
+ html! {
+ <>
+
+
+
+ >
+ }
+}
+
+#[function_component]
+pub fn Pagination(props: &Props) -> Html {
+ html! {
+
+ }
+}
diff --git a/examples/function_router/src/components/post_card.rs b/examples/function_router/src/components/post_card.rs
new file mode 100644
index 00000000000..c48d3f599b4
--- /dev/null
+++ b/examples/function_router/src/components/post_card.rs
@@ -0,0 +1,67 @@
+use std::rc::Rc;
+
+use crate::{content::PostMeta, generator::Generated, Route};
+use yew::prelude::*;
+use yew_router::components::Link;
+
+#[derive(Clone, Debug, PartialEq, Properties)]
+pub struct Props {
+ pub seed: u32,
+}
+
+#[derive(PartialEq, Debug)]
+pub struct PostMetaState {
+ inner: PostMeta,
+}
+
+impl Reducible for PostMetaState {
+ type Action = u32;
+
+ fn reduce(self: Rc, action: u32) -> Rc {
+ Self {
+ inner: PostMeta::generate_from_seed(action),
+ }
+ .into()
+ }
+}
+
+#[function_component]
+pub fn PostCard(props: &Props) -> Html {
+ let seed = props.seed;
+
+ let post = use_reducer_eq(|| PostMetaState {
+ inner: PostMeta::generate_from_seed(seed),
+ });
+
+ {
+ let post_dispatcher = post.dispatcher();
+ use_effect_with_deps(
+ move |seed| {
+ post_dispatcher.dispatch(*seed);
+
+ || {}
+ },
+ seed,
+ );
+ }
+
+ let post = &post.inner;
+
+ html! {
+
+
+
+
+
+
+
+ classes={classes!("title", "is-block")} to={Route::Post { id: post.seed }}>
+ { &post.title }
+ >
+ classes={classes!("subtitle", "is-block")} to={Route::Author { id: post.author.seed }}>
+ { &post.author.name }
+ >
+
+
+ }
+}
diff --git a/examples/function_router/src/components/progress_delay.rs b/examples/function_router/src/components/progress_delay.rs
new file mode 100644
index 00000000000..f017aa46723
--- /dev/null
+++ b/examples/function_router/src/components/progress_delay.rs
@@ -0,0 +1,116 @@
+use std::rc::Rc;
+
+use gloo_timers::callback::Interval;
+use instant::Instant;
+use yew::prelude::*;
+
+const RESOLUTION: u32 = 500;
+const MIN_INTERVAL_MS: u32 = 50;
+
+pub enum ValueAction {
+ Tick,
+ Props(Props),
+}
+
+#[derive(Clone, PartialEq, Debug)]
+pub struct ValueState {
+ start: Instant,
+
+ value: f64,
+
+ props: Props,
+}
+
+impl Reducible for ValueState {
+ type Action = ValueAction;
+
+ fn reduce(self: Rc, action: Self::Action) -> Rc {
+ match action {
+ Self::Action::Props(props) => Self {
+ start: self.start,
+ value: self.value,
+ props,
+ }
+ .into(),
+
+ Self::Action::Tick => {
+ let elapsed = self.start.elapsed().as_millis() as u32;
+ let value = elapsed as f64 / self.props.duration_ms as f64;
+
+ let mut start = self.start;
+
+ if elapsed > self.props.duration_ms {
+ self.props.on_complete.emit(());
+ start = Instant::now();
+ } else {
+ self.props.on_progress.emit(self.value);
+ }
+
+ Self {
+ start,
+ value,
+ props: self.props.clone(),
+ }
+ .into()
+ }
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Properties)]
+pub struct Props {
+ pub duration_ms: u32,
+ pub on_complete: Callback<()>,
+ #[prop_or_default]
+ pub on_progress: Callback,
+}
+
+#[function_component]
+pub fn ProgressDelay(props: &Props) -> Html {
+ let Props { duration_ms, .. } = props.clone();
+
+ let value = {
+ let props = props.clone();
+ use_reducer(move || ValueState {
+ start: Instant::now(),
+ value: 0.0,
+
+ props,
+ })
+ };
+
+ {
+ let value = value.clone();
+ use_effect_with_deps(
+ move |_| {
+ let interval = (duration_ms / RESOLUTION).min(MIN_INTERVAL_MS);
+ let interval =
+ Interval::new(interval as u32, move || value.dispatch(ValueAction::Tick));
+
+ || {
+ let _interval = interval;
+ }
+ },
+ (),
+ );
+ }
+
+ {
+ let value = value.clone();
+ use_effect_with_deps(
+ move |props| {
+ value.dispatch(ValueAction::Props(props.clone()));
+ || {}
+ },
+ props.clone(),
+ );
+ }
+
+ let value = &value.value;
+
+ html! {
+
+ { format!("{:.0}%", 100.0 * value) }
+
+ }
+}
diff --git a/examples/function_router/src/content.rs b/examples/function_router/src/content.rs
new file mode 100644
index 00000000000..752160e8ee0
--- /dev/null
+++ b/examples/function_router/src/content.rs
@@ -0,0 +1,128 @@
+use crate::generator::{Generated, Generator};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Author {
+ pub seed: u32,
+ pub name: String,
+ pub keywords: Vec,
+ pub image_url: String,
+}
+
+impl Generated for Author {
+ fn generate(gen: &mut Generator) -> Self {
+ let name = gen.human_name();
+ let keywords = gen.keywords();
+ let image_url = gen.face_image_url((600, 600));
+ Self {
+ seed: gen.seed,
+ name,
+ keywords,
+ image_url,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PostMeta {
+ pub seed: u32,
+ pub title: String,
+ pub author: Author,
+ pub keywords: Vec,
+ pub image_url: String,
+}
+
+impl Generated for PostMeta {
+ fn generate(gen: &mut Generator) -> Self {
+ let title = gen.title();
+ let author = Author::generate_from_seed(gen.new_seed());
+ let keywords = gen.keywords();
+ let image_url = gen.image_url((1000, 500), &keywords);
+
+ Self {
+ seed: gen.seed,
+ title,
+ author,
+ keywords,
+ image_url,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Post {
+ pub meta: PostMeta,
+ pub content: Vec,
+}
+
+impl Generated for Post {
+ fn generate(gen: &mut Generator) -> Self {
+ const PARTS_MIN: u32 = 1;
+ const PARTS_MAX: u32 = 10;
+
+ let meta = PostMeta::generate(gen);
+
+ let n_parts = gen.range(PARTS_MIN, PARTS_MAX);
+ let content = (0..n_parts).map(|_| PostPart::generate(gen)).collect();
+
+ Self { meta, content }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum PostPart {
+ Section(Section),
+ Quote(Quote),
+}
+
+impl Generated for PostPart {
+ fn generate(gen: &mut Generator) -> Self {
+ // Because we pass the same (already used) generator down,
+ // the resulting `Section` and `Quote` aren't be reproducible with just the seed.
+ // This doesn't matter here though, because we don't need it.
+ if gen.chance(1, 10) {
+ Self::Quote(Quote::generate(gen))
+ } else {
+ Self::Section(Section::generate(gen))
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Section {
+ pub title: String,
+ pub paragraphs: Vec,
+ pub image_url: String,
+}
+
+impl Generated for Section {
+ fn generate(gen: &mut Generator) -> Self {
+ const PARAGRAPHS_MIN: u32 = 1;
+ const PARAGRAPHS_MAX: u32 = 8;
+
+ let title = gen.title();
+ let n_paragraphs = gen.range(PARAGRAPHS_MIN, PARAGRAPHS_MAX);
+ let paragraphs = (0..n_paragraphs).map(|_| gen.paragraph()).collect();
+ let image_url = gen.image_url((600, 300), &[]);
+
+ Self {
+ title,
+ paragraphs,
+ image_url,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Quote {
+ pub author: Author,
+ pub content: String,
+}
+
+impl Generated for Quote {
+ fn generate(gen: &mut Generator) -> Self {
+ // wouldn't it be funny if the author ended up quoting themselves?
+ let author = Author::generate_from_seed(gen.new_seed());
+ let content = gen.paragraph();
+ Self { author, content }
+ }
+}
diff --git a/examples/function_router/src/generator.rs b/examples/function_router/src/generator.rs
new file mode 100644
index 00000000000..b6d4da6b0fe
--- /dev/null
+++ b/examples/function_router/src/generator.rs
@@ -0,0 +1,161 @@
+use lazy_static::lazy_static;
+use lipsum::MarkovChain;
+use rand::{distributions::Bernoulli, rngs::StdRng, seq::IteratorRandom, Rng, SeedableRng};
+
+const KEYWORDS: &str = include_str!("../data/keywords.txt");
+const SYLLABLES: &str = include_str!("../data/syllables.txt");
+const YEW_CONTENT: &str = include_str!("../data/yew.txt");
+
+lazy_static! {
+ static ref YEW_CHAIN: MarkovChain<'static> = {
+ let mut chain = MarkovChain::new();
+ chain.learn(YEW_CONTENT);
+ chain
+ };
+}
+
+pub struct Generator {
+ pub seed: u32,
+ rng: StdRng,
+}
+impl Generator {
+ pub fn from_seed(seed: u32) -> Self {
+ let rng = StdRng::seed_from_u64(seed as u64);
+
+ Self { seed, rng }
+ }
+}
+impl Generator {
+ pub fn new_seed(&mut self) -> u32 {
+ self.rng.gen()
+ }
+
+ /// [low, high)
+ pub fn range(&mut self, low: u32, high: u32) -> u32 {
+ self.rng.gen_range(low..high)
+ }
+
+ /// `n / d` chance
+ pub fn chance(&mut self, n: u32, d: u32) -> bool {
+ self.rng.sample(Bernoulli::from_ratio(n, d).unwrap())
+ }
+
+ pub fn image_url(&mut self, dimension: (u32, u32), keywords: &[String]) -> String {
+ let cache_buster = self.rng.gen::();
+ let (width, height) = dimension;
+ format!(
+ "https://source.unsplash.com/random/{}x{}?{}&sig={}",
+ width,
+ height,
+ keywords.join(","),
+ cache_buster
+ )
+ }
+
+ pub fn face_image_url(&mut self, dimension: (u32, u32)) -> String {
+ self.image_url(dimension, &["human".to_owned(), "face".to_owned()])
+ }
+
+ pub fn human_name(&mut self) -> String {
+ const SYLLABLES_MIN: u32 = 1;
+ const SYLLABLES_MAX: u32 = 5;
+
+ let n_syllables = self.rng.gen_range(SYLLABLES_MIN..SYLLABLES_MAX);
+ let first_name = SYLLABLES
+ .split_whitespace()
+ .choose_multiple(&mut self.rng, n_syllables as usize)
+ .join("");
+
+ let n_syllables = self.rng.gen_range(SYLLABLES_MIN..SYLLABLES_MAX);
+ let last_name = SYLLABLES
+ .split_whitespace()
+ .choose_multiple(&mut self.rng, n_syllables as usize)
+ .join("");
+
+ format!("{} {}", title_case(&first_name), title_case(&last_name))
+ }
+
+ pub fn keywords(&mut self) -> Vec {
+ const KEYWORDS_MIN: u32 = 1;
+ const KEYWORDS_MAX: u32 = 4;
+
+ let n_keywords = self.rng.gen_range(KEYWORDS_MIN..KEYWORDS_MAX);
+ KEYWORDS
+ .split_whitespace()
+ .map(ToOwned::to_owned)
+ .choose_multiple(&mut self.rng, n_keywords as usize)
+ }
+
+ pub fn title(&mut self) -> String {
+ const WORDS_MIN: u32 = 3;
+ const WORDS_MAX: u32 = 8;
+ const SMALL_WORD_LEN: u32 = 3;
+
+ let n_words = self.rng.gen_range(WORDS_MIN..WORDS_MAX);
+
+ let mut title = String::new();
+
+ let words = YEW_CHAIN
+ .iter_with_rng(&mut self.rng)
+ .map(|word| word.trim_matches(|c: char| c.is_ascii_punctuation()))
+ .filter(|word| !word.is_empty())
+ .take(n_words as usize);
+
+ for (i, word) in words.enumerate() {
+ if i > 0 {
+ title.push(' ');
+ }
+
+ // Capitalize the first word and all long words.
+ if i == 0 || word.len() > SMALL_WORD_LEN as usize {
+ title.push_str(&title_case(word));
+ } else {
+ title.push_str(word);
+ }
+ }
+ title
+ }
+
+ pub fn sentence(&mut self) -> String {
+ const WORDS_MIN: u32 = 7;
+ const WORDS_MAX: u32 = 25;
+
+ let n_words = self.rng.gen_range(WORDS_MIN..WORDS_MAX);
+ YEW_CHAIN.generate_with_rng(&mut self.rng, n_words as usize)
+ }
+
+ pub fn paragraph(&mut self) -> String {
+ const SENTENCES_MIN: u32 = 3;
+ const SENTENCES_MAX: u32 = 20;
+
+ let n_sentences = self.rng.gen_range(SENTENCES_MIN..SENTENCES_MAX);
+ let mut paragraph = String::new();
+ for i in 0..n_sentences {
+ if i > 0 {
+ paragraph.push(' ');
+ }
+
+ paragraph.push_str(&self.sentence());
+ }
+ paragraph
+ }
+}
+
+fn title_case(word: &str) -> String {
+ let idx = match word.chars().next() {
+ Some(c) => c.len_utf8(),
+ None => 0,
+ };
+
+ let mut result = String::with_capacity(word.len());
+ result.push_str(&word[..idx].to_uppercase());
+ result.push_str(&word[idx..]);
+ result
+}
+
+pub trait Generated: Sized {
+ fn generate(gen: &mut Generator) -> Self;
+ fn generate_from_seed(seed: u32) -> Self {
+ Self::generate(&mut Generator::from_seed(seed))
+ }
+}
diff --git a/examples/function_router/src/lib.rs b/examples/function_router/src/lib.rs
new file mode 100644
index 00000000000..7ec2d5bd52e
--- /dev/null
+++ b/examples/function_router/src/lib.rs
@@ -0,0 +1,9 @@
+mod app;
+mod components;
+mod content;
+mod generator;
+mod pages;
+
+pub use app::*;
+pub use content::*;
+pub use generator::*;
diff --git a/examples/function_router/src/main.rs b/examples/function_router/src/main.rs
new file mode 100644
index 00000000000..1b16d4978ff
--- /dev/null
+++ b/examples/function_router/src/main.rs
@@ -0,0 +1,13 @@
+mod app;
+mod components;
+mod content;
+mod generator;
+mod pages;
+
+pub use app::*;
+
+fn main() {
+ #[cfg(target_arch = "wasm32")]
+ wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
+ yew::start_app::();
+}
diff --git a/examples/function_router/src/pages/author.rs b/examples/function_router/src/pages/author.rs
new file mode 100644
index 00000000000..1e3bafafc01
--- /dev/null
+++ b/examples/function_router/src/pages/author.rs
@@ -0,0 +1,68 @@
+use crate::components::author_card::AuthorState;
+use crate::{content, generator::Generated};
+use yew::prelude::*;
+
+#[derive(Clone, Debug, Eq, PartialEq, Properties)]
+pub struct Props {
+ pub seed: u32,
+}
+
+#[function_component]
+pub fn Author(props: &Props) -> Html {
+ let seed = props.seed;
+
+ let author = use_reducer_eq(|| AuthorState {
+ inner: content::Author::generate_from_seed(seed),
+ });
+
+ {
+ let author_dispatcher = author.dispatcher();
+ use_effect_with_deps(
+ move |seed| {
+ author_dispatcher.dispatch(*seed);
+
+ || {}
+ },
+ seed,
+ );
+ }
+
+ let author = &author.inner;
+
+ html! {
+
+
+
+
+
+
+ { "Interests" }
+
+ { for author.keywords.iter().map(|tag| html! { { tag } }) }
+
+
+
+
+
+
+
+
+
+
+
+
{ "About me" }
+
+ { "This author has chosen not to reveal anything about themselves" }
+
+
+
+
+
+
+
+ }
+}
diff --git a/examples/function_router/src/pages/author_list.rs b/examples/function_router/src/pages/author_list.rs
new file mode 100644
index 00000000000..005acd3de20
--- /dev/null
+++ b/examples/function_router/src/pages/author_list.rs
@@ -0,0 +1,64 @@
+use crate::components::{author_card::AuthorCard, progress_delay::ProgressDelay};
+use rand::{distributions, Rng};
+use yew::prelude::*;
+
+/// Amount of milliseconds to wait before showing the next set of authors.
+const CAROUSEL_DELAY_MS: u32 = 15000;
+
+#[function_component]
+pub fn AuthorList() -> Html {
+ let seeds = use_state(random_author_seeds);
+
+ let authors = seeds.iter().map(|&seed| {
+ html! {
+
+ }
+ });
+
+ let on_complete = {
+ let seeds = seeds.clone();
+
+ Callback::from(move |_| {
+ seeds.set(random_author_seeds());
+ })
+ };
+
+ html! {
+
+
+
+
+
{ "Authors" }
+
+ { "Meet the definitely real people behind your favourite Yew content" }
+
+
+
+
+
+ { "It wouldn't be fair " }
+ { "(or possible :P)" }
+ {" to list each and every author in alphabetical order."}
+
+ { "So instead we chose to put more focus on the individuals by introducing you to two people at a time" }
+
+
+
+ { for authors }
+
+
+
+
+ }
+}
+
+fn random_author_seeds() -> Vec {
+ rand::thread_rng()
+ .sample_iter(distributions::Standard)
+ .take(2)
+ .collect()
+}
diff --git a/examples/function_router/src/pages/home.rs b/examples/function_router/src/pages/home.rs
new file mode 100644
index 00000000000..69c2b9035ed
--- /dev/null
+++ b/examples/function_router/src/pages/home.rs
@@ -0,0 +1,67 @@
+use yew::prelude::*;
+
+#[function_component]
+fn InfoTiles() -> Html {
+ html! {
+ <>
+
+
+
{ "What are yews?" }
+
{ "Everything you need to know!" }
+
+
+ {r#"
+ A yew is a small to medium-sized evergreen tree, growing 10 to 20 metres tall, with a trunk up to 2 metres in diameter.
+ The bark is thin, scaly brown, coming off in small flakes aligned with the stem.
+ The leaves are flat, dark green, 1 to 4 centimetres long and 2 to 3 millimetres broad, arranged spirally on the stem,
+ but with the leaf bases twisted to align the leaves in two flat rows either side of the stem,
+ except on erect leading shoots where the spiral arrangement is more obvious.
+ The leaves are poisonous.
+ "#}
+
+
+
+
+
+
+
{ "Who are we?" }
+
+
+ { "We're a small team of just 2" }
+ { 64 }
+ { " members working tirelessly to bring you the low-effort yew content we all desperately crave." }
+
+ {r#"
+ We put a ton of effort into fact-checking our posts.
+ Some say they read like a Wikipedia article - what a compliment!
+ "#}
+
+
+
+ >
+ }
+}
+
+#[function_component]
+pub fn Home() -> Html {
+ html! {
+
+
+
+
{ "Welcome..." }
+ { "...to the best yew content" }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+}
diff --git a/examples/function_router/src/pages/mod.rs b/examples/function_router/src/pages/mod.rs
new file mode 100644
index 00000000000..59656d03013
--- /dev/null
+++ b/examples/function_router/src/pages/mod.rs
@@ -0,0 +1,6 @@
+pub mod author;
+pub mod author_list;
+pub mod home;
+pub mod page_not_found;
+pub mod post;
+pub mod post_list;
diff --git a/examples/function_router/src/pages/page_not_found.rs b/examples/function_router/src/pages/page_not_found.rs
new file mode 100644
index 00000000000..20ace7dbc9e
--- /dev/null
+++ b/examples/function_router/src/pages/page_not_found.rs
@@ -0,0 +1,19 @@
+use yew::prelude::*;
+
+#[function_component]
+pub fn PageNotFound() -> Html {
+ html! {
+
+
+
+
+ { "Page not found" }
+
+
+ { "Page page does not seem to exist" }
+
+
+
+
+ }
+}
diff --git a/examples/function_router/src/pages/post.rs b/examples/function_router/src/pages/post.rs
new file mode 100644
index 00000000000..e1ecbf4d101
--- /dev/null
+++ b/examples/function_router/src/pages/post.rs
@@ -0,0 +1,157 @@
+use std::rc::Rc;
+
+use crate::{content, generator::Generated, Route};
+use content::PostPart;
+use yew::prelude::*;
+use yew_router::prelude::*;
+
+#[derive(Clone, Debug, Eq, PartialEq, Properties)]
+pub struct Props {
+ pub seed: u32,
+}
+
+#[derive(PartialEq, Debug)]
+pub struct PostState {
+ pub inner: content::Post,
+}
+
+impl Reducible for PostState {
+ type Action = u32;
+
+ fn reduce(self: Rc, action: u32) -> Rc {
+ Self {
+ inner: content::Post::generate_from_seed(action),
+ }
+ .into()
+ }
+}
+
+#[function_component]
+pub fn Post(props: &Props) -> Html {
+ let seed = props.seed;
+
+ let post = use_reducer(|| PostState {
+ inner: content::Post::generate_from_seed(seed),
+ });
+
+ {
+ let post_dispatcher = post.dispatcher();
+ use_effect_with_deps(
+ move |seed| {
+ post_dispatcher.dispatch(*seed);
+
+ || {}
+ },
+ seed,
+ );
+ }
+
+ let post = &post.inner;
+
+ let render_quote = |quote: &content::Quote| {
+ html! {
+
+
+
+
+
+
+
+
+
classes={classes!("is-size-5")} to={Route::Author { id: quote.author.seed }}>
+
{ "e.author.name }
+ >
+
+ { "e.content }
+
+
+
+
+ }
+ };
+
+ let render_section_hero = |section: &content::Section| {
+ html! {
+
+
+
+
+
{ §ion.title }
+
+
+
+ }
+ };
+
+ let render_section = |section, show_hero| {
+ let hero = if show_hero {
+ render_section_hero(section)
+ } else {
+ html! {}
+ };
+ let paragraphs = section.paragraphs.iter().map(|paragraph| {
+ html! {
+ { paragraph }
+ }
+ });
+ html! {
+
+ { hero }
+ { for paragraphs }
+
+ }
+ };
+
+ let view_content = {
+ // don't show hero for the first section
+ let mut show_hero = false;
+
+ let parts = post.content.iter().map(|part| match part {
+ PostPart::Section(section) => {
+ let html = render_section(section, show_hero);
+ // show hero between sections
+ show_hero = true;
+ html
+ }
+ PostPart::Quote(quote) => {
+ // don't show hero after a quote
+ show_hero = false;
+ render_quote(quote)
+ }
+ });
+ html! { for parts }
+ };
+
+ let keywords = post
+ .meta
+ .keywords
+ .iter()
+ .map(|keyword| html! { { keyword } });
+
+ html! {
+ <>
+
+
+
+
+
+ { &post.meta.title }
+
+
+ { "by " }
+ classes={classes!("has-text-weight-semibold")} to={Route::Author { id: post.meta.author.seed }}>
+ { &post.meta.author.name }
+ >
+
+
+ { for keywords }
+
+
+
+
+
+ { view_content }
+
+ >
+ }
+}
diff --git a/examples/function_router/src/pages/post_list.rs b/examples/function_router/src/pages/post_list.rs
new file mode 100644
index 00000000000..264cf5cc05c
--- /dev/null
+++ b/examples/function_router/src/pages/post_list.rs
@@ -0,0 +1,52 @@
+use crate::components::pagination::PageQuery;
+use crate::components::{pagination::Pagination, post_card::PostCard};
+use crate::Route;
+use yew::prelude::*;
+use yew_router::prelude::*;
+
+const ITEMS_PER_PAGE: u32 = 10;
+const TOTAL_PAGES: u32 = u32::MAX / ITEMS_PER_PAGE;
+
+#[function_component]
+pub fn PostList() -> Html {
+ let location = use_location().unwrap();
+ let current_page = location.query::().map(|it| it.page).unwrap_or(1);
+
+ let posts = {
+ let start_seed = (current_page - 1) * ITEMS_PER_PAGE;
+ let mut cards = (0..ITEMS_PER_PAGE).map(|seed_offset| {
+ html! {
+
+
+
+ }
+ });
+ html! {
+
+
+
+ { for cards.by_ref().take(ITEMS_PER_PAGE as usize / 2) }
+
+
+
+
+ }
+ };
+
+ html! {
+
+
{ "Posts" }
+
{ "All of our quality writing in one place" }
+ { posts }
+
+
+ }
+}
diff --git a/examples/simple_ssr/Cargo.toml b/examples/simple_ssr/Cargo.toml
index d4812fcc79b..50f879f0c5c 100644
--- a/examples/simple_ssr/Cargo.toml
+++ b/examples/simple_ssr/Cargo.toml
@@ -6,9 +6,20 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
-tokio = { version = "1.15.0", features = ["full"] }
-warp = "0.3"
-yew = { path = "../../packages/yew", features = ["ssr"] }
+yew = { path = "../../packages/yew", features = ["ssr", "hydration"] }
reqwest = { version = "0.11.8", features = ["json"] }
serde = { version = "1.0.132", features = ["derive"] }
uuid = { version = "0.8.2", features = ["serde"] }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen-futures = "0.4"
+wasm-logger = "0.2"
+log = "0.4"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+tokio = { version = "1.15.0", features = ["full"] }
+warp = "0.3"
+structopt = "0.3"
+num_cpus = "1.13"
+tokio-util = { version = "0.7", features = ["rt"] }
+once_cell = "1.5"
diff --git a/examples/simple_ssr/README.md b/examples/simple_ssr/README.md
index 95cf18b43ea..6c02a63edf3 100644
--- a/examples/simple_ssr/README.md
+++ b/examples/simple_ssr/README.md
@@ -2,5 +2,16 @@
This example demonstrates server-side rendering.
-Run `cargo run -p simple_ssr` and navigate to http://localhost:8080/ to
-view results.
+# How to run this example
+
+1. build hydration bundle
+
+`trunk build examples/simple_ssr/index.html`
+
+2. Run the server
+
+`cargo run --bin simple_ssr_server -- --dir examples/simple_ssr/dist`
+
+3. Open Browser
+
+Navigate to http://localhost:8080/ to view results.
diff --git a/examples/simple_ssr/index.html b/examples/simple_ssr/index.html
new file mode 100644
index 00000000000..62951cf4073
--- /dev/null
+++ b/examples/simple_ssr/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+ Yew SSR Example
+
+
+
+
diff --git a/examples/simple_ssr/src/bin/simple_ssr_hydrate.rs b/examples/simple_ssr/src/bin/simple_ssr_hydrate.rs
new file mode 100644
index 00000000000..858995bcf90
--- /dev/null
+++ b/examples/simple_ssr/src/bin/simple_ssr_hydrate.rs
@@ -0,0 +1,8 @@
+use simple_ssr::App;
+use yew::prelude::*;
+
+fn main() {
+ #[cfg(target_arch = "wasm32")]
+ wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
+ Renderer::::new().hydrate();
+}
diff --git a/examples/simple_ssr/src/bin/simple_ssr_server.rs b/examples/simple_ssr/src/bin/simple_ssr_server.rs
new file mode 100644
index 00000000000..17a20d17cae
--- /dev/null
+++ b/examples/simple_ssr/src/bin/simple_ssr_server.rs
@@ -0,0 +1,53 @@
+use once_cell::sync::Lazy;
+use simple_ssr::App;
+use std::path::PathBuf;
+use structopt::StructOpt;
+use tokio_util::task::LocalPoolHandle;
+use warp::Filter;
+
+// We spawn a local pool that is as big as the number of cpu threads.
+static LOCAL_POOL: Lazy = Lazy::new(|| LocalPoolHandle::new(num_cpus::get()));
+
+/// A basic example
+#[derive(StructOpt, Debug)]
+struct Opt {
+ /// the "dist" created by trunk directory to be served for hydration.
+ #[structopt(short, long, parse(from_os_str))]
+ dir: PathBuf,
+}
+
+async fn render(index_html_s: &str) -> String {
+ let content = LOCAL_POOL
+ .spawn_pinned(move || async move {
+ let renderer = yew::ServerRenderer::::new();
+
+ renderer.render().await
+ })
+ .await
+ .expect("the task has failed.");
+
+ // Good enough for an example, but developers should avoid the replace and extra allocation
+ // here in an actual app.
+ index_html_s.replace("", &format!("{}", content))
+}
+
+#[tokio::main]
+async fn main() {
+ let opts = Opt::from_args();
+
+ let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
+ .await
+ .expect("failed to read index.html");
+
+ let html = warp::path::end().then(move || {
+ let index_html_s = index_html_s.clone();
+
+ async move { warp::reply::html(render(&index_html_s).await) }
+ });
+
+ let routes = html.or(warp::fs::dir(opts.dir));
+
+ println!("You can view the website at: http://localhost:8080/");
+
+ warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;
+}
diff --git a/examples/simple_ssr/src/main.rs b/examples/simple_ssr/src/lib.rs
similarity index 64%
rename from examples/simple_ssr/src/main.rs
rename to examples/simple_ssr/src/lib.rs
index 58dbb0dda8d..ad7af2e0bba 100644
--- a/examples/simple_ssr/src/main.rs
+++ b/examples/simple_ssr/src/lib.rs
@@ -2,13 +2,15 @@ use std::cell::RefCell;
use std::rc::Rc;
use serde::{Deserialize, Serialize};
-use tokio::task::LocalSet;
-use tokio::task::{spawn_blocking, spawn_local};
use uuid::Uuid;
-use warp::Filter;
use yew::prelude::*;
use yew::suspense::{Suspension, SuspensionResult};
+#[cfg(not(target_arch = "wasm32"))]
+use tokio::task::spawn_local;
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen_futures::spawn_local;
+
#[derive(Serialize, Deserialize)]
struct UuidResponse {
uuid: Uuid,
@@ -79,7 +81,7 @@ fn Content() -> HtmlResult {
}
#[function_component]
-fn App() -> Html {
+pub fn App() -> Html {
let fallback = html! {{"Loading..."}
};
html! {
@@ -88,43 +90,3 @@ fn App() -> Html {
}
}
-
-async fn render() -> String {
- let content = spawn_blocking(move || {
- use tokio::runtime::Builder;
- let set = LocalSet::new();
-
- let rt = Builder::new_current_thread().enable_all().build().unwrap();
-
- set.block_on(&rt, async {
- let renderer = yew::ServerRenderer::::new();
-
- renderer.render().await
- })
- })
- .await
- .expect("the thread has failed.");
-
- format!(
- r#"
-
-
- Yew SSR Example
-
-
- {}
-
-
-"#,
- content
- )
-}
-
-#[tokio::main]
-async fn main() {
- let routes = warp::any().then(|| async move { warp::reply::html(render().await) });
-
- println!("You can view the website at: http://localhost:8080/");
-
- warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;
-}
diff --git a/examples/ssr_router/Cargo.toml b/examples/ssr_router/Cargo.toml
new file mode 100644
index 00000000000..b6873c9d468
--- /dev/null
+++ b/examples/ssr_router/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "ssr_router"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+yew = { path = "../../packages/yew", features = ["ssr", "hydration"] }
+function_router = { path = "../function_router" }
+log = "0.4"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen-futures = "0.4"
+wasm-logger = "0.2"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+tokio = { version = "1.15.0", features = ["full"] }
+warp = "0.3"
+structopt = "0.3"
+env_logger = "0.9"
+num_cpus = "1.13"
+tokio-util = { version = "0.7", features = ["rt"] }
+once_cell = "1.5"
diff --git a/examples/ssr_router/README.md b/examples/ssr_router/README.md
new file mode 100644
index 00000000000..7865b2d0513
--- /dev/null
+++ b/examples/ssr_router/README.md
@@ -0,0 +1,19 @@
+# SSR Router Example
+
+This example is the same as the function router example, but with
+server-side rendering and hydration support. It reuses the same codebase
+of the function router example.
+
+# How to run this example
+
+1. build hydration bundle
+
+`trunk build examples/ssr_router/index.html`
+
+2. Run the server
+
+`cargo run --bin ssr_router_server -- --dir examples/ssr_router/dist`
+
+3. Open Browser
+
+Navigate to http://localhost:8080/ to view results.
diff --git a/examples/ssr_router/index.html b/examples/ssr_router/index.html
new file mode 100644
index 00000000000..95eb7d33dd4
--- /dev/null
+++ b/examples/ssr_router/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ Yew • SSR Router
+
+
+
+
diff --git a/examples/ssr_router/src/bin/ssr_router_hydrate.rs b/examples/ssr_router/src/bin/ssr_router_hydrate.rs
new file mode 100644
index 00000000000..74d90b112f5
--- /dev/null
+++ b/examples/ssr_router/src/bin/ssr_router_hydrate.rs
@@ -0,0 +1,9 @@
+use yew::prelude::*;
+
+use function_router::App;
+
+fn main() {
+ #[cfg(target_arch = "wasm32")]
+ wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
+ Renderer::::new().hydrate();
+}
diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/ssr_router/src/bin/ssr_router_server.rs
new file mode 100644
index 00000000000..fcea83c105e
--- /dev/null
+++ b/examples/ssr_router/src/bin/ssr_router_server.rs
@@ -0,0 +1,71 @@
+use function_router::{ServerApp, ServerAppProps};
+use once_cell::sync::Lazy;
+use std::collections::HashMap;
+use std::path::PathBuf;
+use structopt::StructOpt;
+use tokio_util::task::LocalPoolHandle;
+use warp::Filter;
+
+// We spawn a local pool that is as big as the number of cpu threads.
+static LOCAL_POOL: Lazy = Lazy::new(|| LocalPoolHandle::new(num_cpus::get()));
+
+/// A basic example
+#[derive(StructOpt, Debug)]
+struct Opt {
+ /// the "dist" created by trunk directory to be served for hydration.
+ #[structopt(short, long, parse(from_os_str))]
+ dir: PathBuf,
+}
+
+async fn render(index_html_s: &str, url: &str, queries: HashMap) -> String {
+ let url = url.to_string();
+
+ let content = LOCAL_POOL
+ .spawn_pinned(move || async move {
+ let server_app_props = ServerAppProps {
+ url: url.into(),
+ queries,
+ };
+
+ let renderer = yew::ServerRenderer::::with_props(server_app_props);
+
+ renderer.render().await
+ })
+ .await
+ .expect("the task has failed.");
+
+ // Good enough for an example, but developers should avoid the replace and extra allocation
+ // here in an actual app.
+ index_html_s.replace("", &format!("{}", content))
+}
+
+#[tokio::main]
+async fn main() {
+ env_logger::init();
+
+ let opts = Opt::from_args();
+
+ let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
+ .await
+ .expect("failed to read index.html");
+
+ let render = move |s: warp::filters::path::FullPath, queries: HashMap| {
+ let index_html_s = index_html_s.clone();
+
+ async move { warp::reply::html(render(&index_html_s, s.as_str(), queries).await) }
+ };
+
+ let html = warp::path::end().and(
+ warp::path::full()
+ .and(warp::filters::query::query())
+ .then(render.clone()),
+ );
+
+ let routes = html.or(warp::fs::dir(opts.dir)).or(warp::path::full()
+ .and(warp::filters::query::query())
+ .then(render));
+
+ println!("You can view the website at: http://localhost:8080/");
+
+ warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;
+}
diff --git a/examples/ssr_router/src/lib.rs b/examples/ssr_router/src/lib.rs
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/examples/ssr_router/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/examples/web_worker_fib/Makefile.toml b/examples/web_worker_fib/Makefile.toml
new file mode 100644
index 00000000000..05982c75323
--- /dev/null
+++ b/examples/web_worker_fib/Makefile.toml
@@ -0,0 +1,4 @@
+[tasks.doc-test]
+private = true
+clear = true
+script = ["exit 0"]
diff --git a/packages/yew-router/src/router.rs b/packages/yew-router/src/router.rs
index add8d80e4de..fa32a77fa76 100644
--- a/packages/yew-router/src/router.rs
+++ b/packages/yew-router/src/router.rs
@@ -58,15 +58,12 @@ impl NavigatorContext {
}
}
-/// The Router component.
-///
-/// This provides location and navigator context to its children and switches.
+/// The base router.
///
-/// If you are building a web application, you may want to consider using [`BrowserRouter`] instead.
-///
-/// You only need one ` ` for each application.
-#[function_component(Router)]
-pub fn router(props: &RouterProps) -> Html {
+/// The implementation is separated to make sure has the same virtual dom layout as
+/// the and .
+#[function_component(BaseRouter)]
+fn base_router(props: &RouterProps) -> Html {
let RouterProps {
history,
children,
@@ -117,6 +114,20 @@ pub fn router(props: &RouterProps) -> Html {
}
}
+/// The Router component.
+///
+/// This provides location and navigator context to its children and switches.
+///
+/// If you are building a web application, you may want to consider using [`BrowserRouter`] instead.
+///
+/// You only need one ` ` for each application.
+#[function_component(Router)]
+pub fn router(props: &RouterProps) -> Html {
+ html! {
+
+ }
+}
+
/// Props for [`BrowserRouter`] and [`HashRouter`].
#[derive(Properties, PartialEq, Clone)]
pub struct ConcreteRouterProps {
@@ -143,9 +154,9 @@ pub fn browser_router(props: &ConcreteRouterProps) -> Html {
let basename = basename.map(|m| m.to_string()).or_else(base_url);
html! {
-
+
{children}
-
+
}
}
@@ -163,8 +174,8 @@ pub fn hash_router(props: &ConcreteRouterProps) -> Html {
let history = use_state(|| AnyHistory::from(HashHistory::new()));
html! {
-
+
{children}
-
+
}
}
diff --git a/packages/yew/Cargo.toml b/packages/yew/Cargo.toml
index fa63c9d84d7..bf693ae286d 100644
--- a/packages/yew/Cargo.toml
+++ b/packages/yew/Cargo.toml
@@ -51,6 +51,7 @@ features = [
"Location",
"MouseEvent",
"Node",
+ "NodeList",
"PointerEvent",
"ProgressEvent",
"Text",
@@ -79,9 +80,11 @@ trybuild = "1"
[features]
doc_test = []
-wasm_test = []
+wasm_test = ["ssr", "hydration"]
wasm_bench = []
ssr = ["futures", "html-escape"]
+hydration = []
+trace_hydration = ["hydration"]
default = []
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
diff --git a/packages/yew/Makefile.toml b/packages/yew/Makefile.toml
index 1a4a0f505e0..16017d016b8 100644
--- a/packages/yew/Makefile.toml
+++ b/packages/yew/Makefile.toml
@@ -1,7 +1,7 @@
[tasks.native-test]
command = "cargo"
toolchain = "1.56"
-args = ["test", "native_"]
+args = ["test", "native_", "--features", "ssr,hydration"]
[tasks.test]
dependencies = ["native-test"]
@@ -26,6 +26,8 @@ args = [
"--doc",
"--features",
"doc_test,wasm_test",
+ "-p",
+ "yew",
]
[tasks.bench]
@@ -44,4 +46,4 @@ args = [
[tasks.ssr-test]
command = "cargo"
-args = ["test", "ssr_tests", "--features", "ssr"]
+args = ["test", "ssr_tests", "--features", "ssr,hydration"]
diff --git a/packages/yew/src/app_handle.rs b/packages/yew/src/app_handle.rs
index 5badcd8c750..c1975040b28 100644
--- a/packages/yew/src/app_handle.rs
+++ b/packages/yew/src/app_handle.rs
@@ -50,6 +50,38 @@ where
}
}
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use crate::virtual_dom::Fragment;
+
+ impl AppHandle
+ where
+ COMP: BaseComponent,
+ {
+ pub(crate) fn hydrate_with_props(element: Element, props: Rc) -> Self {
+ let app = Self {
+ scope: Scope::new(None),
+ };
+
+ let mut fragment = Fragment::collect_children(&element);
+
+ app.scope
+ .hydrate_in_place(element.clone(), &mut fragment, NodeRef::default(), props);
+
+ // We remove all remaining nodes, this mimics the clear_element behaviour in
+ // mount_with_props.
+ for node in fragment.iter() {
+ element.remove_child(node).unwrap();
+ }
+
+ app
+ }
+ }
+}
+
/// Removes anything from the given element.
fn clear_element(element: &Element) {
while let Some(child) = element.last_child() {
diff --git a/packages/yew/src/html/component/children.rs b/packages/yew/src/html/component/children.rs
index c0fbd1d858d..5498b61960e 100644
--- a/packages/yew/src/html/component/children.rs
+++ b/packages/yew/src/html/component/children.rs
@@ -1,6 +1,6 @@
//! Component children module
-use crate::html::Html;
+use crate::html::{Html, Properties};
use crate::virtual_dom::{VChild, VNode};
use std::fmt;
@@ -208,3 +208,11 @@ impl IntoIterator for ChildrenRenderer {
self.children.into_iter()
}
}
+
+/// A [Properties] type with Children being the only property.
+#[derive(Debug, Properties, PartialEq)]
+pub struct ChildrenProps {
+ /// The Children of a Component.
+ #[prop_or_default]
+ pub children: Children,
+}
diff --git a/packages/yew/src/html/component/lifecycle.rs b/packages/yew/src/html/component/lifecycle.rs
index 15595b91de0..c3c295fe0b8 100644
--- a/packages/yew/src/html/component/lifecycle.rs
+++ b/packages/yew/src/html/component/lifecycle.rs
@@ -3,7 +3,9 @@
use super::{AnyScope, BaseComponent, Scope};
use crate::html::{RenderError, RenderResult};
use crate::scheduler::{self, Runnable, Shared};
-use crate::suspense::{Suspense, Suspension};
+use crate::suspense::{BaseSuspense, Suspension};
+#[cfg(feature = "hydration")]
+use crate::virtual_dom::{Fragment, VHydrate};
use crate::virtual_dom::{VDiff, VNode};
use crate::Callback;
use crate::{Context, NodeRef};
@@ -13,6 +15,45 @@ use std::any::Any;
use std::rc::Rc;
use web_sys::Element;
+/// A State to track current component rendering status.
+pub(crate) enum Rendered {
+ Render {
+ parent: Element,
+
+ next_sibling: NodeRef,
+ node_ref: NodeRef,
+
+ root_node: VNode,
+ },
+ #[cfg(feature = "hydration")]
+ Hydration {
+ parent: Element,
+
+ next_sibling: NodeRef,
+ node_ref: NodeRef,
+
+ fragment: Fragment,
+ },
+ #[cfg(feature = "ssr")]
+ Ssr {
+ sender: Option>,
+ },
+}
+
+impl Rendered {
+ pub fn root_vnode(&self) -> Option<&VNode> {
+ match self {
+ Rendered::Render { ref root_node, .. } => Some(root_node),
+
+ #[cfg(feature = "hydration")]
+ Rendered::Hydration { .. } => None,
+
+ #[cfg(feature = "ssr")]
+ Rendered::Ssr { .. } => None,
+ }
+ }
+}
+
pub(crate) struct CompStateInner
where
COMP: BaseComponent,
@@ -27,6 +68,8 @@ where
/// Mostly a thin wrapper that passes the context to a component's lifecycle
/// methods.
pub(crate) trait Stateful {
+ fn id(&self) -> usize;
+
fn view(&self) -> RenderResult;
fn rendered(&mut self, first_render: bool);
fn destroy(&mut self);
@@ -44,6 +87,10 @@ impl Stateful for CompStateInner
where
COMP: BaseComponent,
{
+ fn id(&self) -> usize {
+ self.context.scope.id
+ }
+
fn view(&self) -> RenderResult {
self.component.view(&self.context)
}
@@ -96,90 +143,49 @@ where
pub(crate) struct ComponentState {
pub(crate) inner: Box,
- pub(crate) root_node: VNode,
-
- /// When a component has no parent, it means that it should not be rendered.
- parent: Option,
+ pub(crate) rendered: Rendered,
- next_sibling: NodeRef,
- node_ref: NodeRef,
has_rendered: bool,
suspension: Option,
-
- #[cfg(feature = "ssr")]
- html_sender: Option>,
-
- // Used for debug logging
- #[cfg(debug_assertions)]
- pub(crate) vcomp_id: usize,
-}
-
-impl ComponentState {
- pub(crate) fn new(
- parent: Option,
- next_sibling: NodeRef,
- root_node: VNode,
- node_ref: NodeRef,
- scope: Scope,
- props: Rc,
- #[cfg(feature = "ssr")] html_sender: Option>,
- ) -> Self {
- #[cfg(debug_assertions)]
- let vcomp_id = scope.vcomp_id;
- let context = Context { scope, props };
-
- let inner = Box::new(CompStateInner {
- component: COMP::create(&context),
- context,
- });
-
- Self {
- inner,
- root_node,
- parent,
- next_sibling,
- node_ref,
- suspension: None,
- has_rendered: false,
-
- #[cfg(feature = "ssr")]
- html_sender,
-
- #[cfg(debug_assertions)]
- vcomp_id,
- }
- }
}
pub(crate) struct CreateRunner {
- pub(crate) parent: Option,
- pub(crate) next_sibling: NodeRef,
- pub(crate) placeholder: VNode,
- pub(crate) node_ref: NodeRef,
+ pub(crate) rendered: Rendered,
pub(crate) props: Rc,
pub(crate) scope: Scope,
- #[cfg(feature = "ssr")]
- pub(crate) html_sender: Option>,
}
impl Runnable for CreateRunner {
fn run(self: Box) {
- let mut current_state = self.scope.state.borrow_mut();
+ let state = self.scope.state.clone();
+ let mut current_state = state.borrow_mut();
+
if current_state.is_none() {
#[cfg(debug_assertions)]
- crate::virtual_dom::vcomp::log_event(self.scope.vcomp_id, "create");
-
- *current_state = Some(ComponentState::new(
- self.parent,
- self.next_sibling,
- self.placeholder,
- self.node_ref,
- self.scope.clone(),
- self.props,
- #[cfg(feature = "ssr")]
- self.html_sender,
- ));
+ crate::virtual_dom::vcomp::log_event(self.scope.id, "create");
+
+ let Self {
+ props,
+ rendered,
+ scope,
+ } = *self;
+
+ let context = Context { scope, props };
+
+ let inner = Box::new(CompStateInner {
+ component: COMP::create(&context),
+ context,
+ });
+
+ let state = ComponentState {
+ inner,
+ rendered,
+ suspension: None,
+ has_rendered: false,
+ };
+
+ *current_state = Some(state);
}
}
}
@@ -200,27 +206,78 @@ pub(crate) struct UpdateRunner {
impl Runnable for UpdateRunner {
fn run(self: Box) {
- if let Some(mut state) = self.state.borrow_mut().as_mut() {
+ if let Some(state) = self.state.borrow_mut().as_mut() {
let schedule_render = match self.event {
UpdateEvent::Message => state.inner.flush_messages(),
- UpdateEvent::Properties(props, node_ref, next_sibling) => {
- // When components are updated, a new node ref could have been passed in
- state.node_ref = node_ref;
- // When components are updated, their siblings were likely also updated
- state.next_sibling = next_sibling;
- // Only trigger changed if props were changed
-
- state.inner.props_changed(props)
+ UpdateEvent::Properties(props, next_node_ref, next_sibling) => {
+ match state.rendered {
+ Rendered::Render {
+ ref mut node_ref,
+ next_sibling: ref mut current_next_sibling,
+ ..
+ } => {
+ // When components are updated, a new node ref could have been passed in
+ *node_ref = next_node_ref;
+ // When components are updated, their siblings were likely also updated
+ *current_next_sibling = next_sibling;
+ // Only trigger changed if props were changed
+ state.inner.props_changed(props)
+ }
+
+ #[cfg(feature = "hydration")]
+ Rendered::Hydration {
+ ref mut node_ref,
+ next_sibling: ref mut current_next_sibling,
+ ..
+ } => {
+ // When components are updated, a new node ref could have been passed in
+ *node_ref = next_node_ref;
+ // When components are updated, their siblings were likely also updated
+ *current_next_sibling = next_sibling;
+ // Only trigger changed if props were changed
+ state.inner.props_changed(props)
+ }
+
+ #[cfg(feature = "ssr")]
+ Rendered::Ssr { .. } => state.inner.props_changed(props),
+ }
}
- UpdateEvent::Shift(parent, next_sibling) => {
- state.root_node.shift(
- state.parent.as_ref().unwrap(),
- &parent,
- next_sibling.clone(),
- );
- state.parent = Some(parent);
- state.next_sibling = next_sibling;
+ UpdateEvent::Shift(next_parent, next_sibling) => {
+ match state.rendered {
+ Rendered::Render {
+ ref root_node,
+ ref mut parent,
+ next_sibling: ref mut current_next_sibling,
+ ..
+ } => {
+ root_node.shift(parent, &next_parent, next_sibling.clone());
+
+ *parent = next_parent;
+ *current_next_sibling = next_sibling;
+ }
+
+ // We need to shift the hydrate fragment if the component is not hydrated.
+ #[cfg(feature = "hydration")]
+ Rendered::Hydration {
+ ref fragment,
+ ref mut parent,
+ next_sibling: ref mut current_next_sibling,
+ ..
+ } => {
+ fragment.shift(parent, &next_parent, next_sibling.clone());
+
+ *parent = next_parent;
+ *current_next_sibling = next_sibling;
+ }
+
+ // Shifting is not possible during SSR.
+ #[cfg(feature = "ssr")]
+ Rendered::Ssr { .. } => {
+ #[cfg(debug_assertions)]
+ panic!("shifting is not possible during SSR");
+ }
+ }
false
}
@@ -228,13 +285,13 @@ impl Runnable for UpdateRunner {
#[cfg(debug_assertions)]
crate::virtual_dom::vcomp::log_event(
- state.vcomp_id,
+ state.inner.id(),
format!("update(schedule_render={})", schedule_render),
);
if schedule_render {
scheduler::push_component_render(
- self.state.as_ptr() as usize,
+ state.inner.id(),
RenderRunner {
state: self.state.clone(),
},
@@ -254,13 +311,40 @@ impl Runnable for DestroyRunner {
fn run(self: Box) {
if let Some(mut state) = self.state.borrow_mut().take() {
#[cfg(debug_assertions)]
- crate::virtual_dom::vcomp::log_event(state.vcomp_id, "destroy");
+ crate::virtual_dom::vcomp::log_event(state.inner.id(), "destroy");
state.inner.destroy();
- if let Some(ref m) = state.parent {
- state.root_node.detach(m, self.parent_to_detach);
- state.node_ref.set(None);
+ match state.rendered {
+ Rendered::Render {
+ ref mut root_node,
+ ref parent,
+ ref node_ref,
+ ..
+ } => {
+ root_node.detach(parent, self.parent_to_detach);
+
+ node_ref.set(None);
+ }
+ // We need to detach the hydrate fragment if the component is not hydrated.
+ #[cfg(feature = "hydration")]
+ Rendered::Hydration {
+ ref fragment,
+ ref parent,
+ ref node_ref,
+ ..
+ } => {
+ for node in fragment.iter() {
+ parent
+ .remove_child(node)
+ .expect("failed to remove fragment node.");
+ }
+
+ node_ref.set(None);
+ }
+
+ #[cfg(feature = "ssr")]
+ Rendered::Ssr { .. } => {}
}
}
}
@@ -270,103 +354,160 @@ pub(crate) struct RenderRunner {
pub(crate) state: Shared>,
}
-impl Runnable for RenderRunner {
- fn run(self: Box) {
- if let Some(state) = self.state.borrow_mut().as_mut() {
- #[cfg(debug_assertions)]
- crate::virtual_dom::vcomp::log_event(state.vcomp_id, "render");
+impl RenderRunner {
+ fn render(&self, state: &mut ComponentState, new_root: VNode) {
+ // Currently not suspended, we remove any previous suspension and update
+ // normally.
- match state.inner.view() {
- Ok(m) => {
- // Currently not suspended, we remove any previous suspension and update
- // normally.
- let mut root = m;
- if state.parent.is_some() {
- std::mem::swap(&mut root, &mut state.root_node);
- }
+ if let Some(m) = state.suspension.take() {
+ let comp_scope = state.inner.any_scope();
- if let Some(m) = state.suspension.take() {
- let comp_scope = state.inner.any_scope();
+ let suspense_scope = comp_scope.find_parent_scope::().unwrap();
+ let suspense = suspense_scope.get_component().unwrap();
- let suspense_scope = comp_scope.find_parent_scope::().unwrap();
- let suspense = suspense_scope.get_component().unwrap();
+ suspense.resume(m);
+ }
- suspense.resume(m);
- }
+ let scope = state.inner.any_scope();
- if let Some(ref m) = state.parent {
- let ancestor = Some(root);
- let new_root = &mut state.root_node;
- let scope = state.inner.any_scope();
- let next_sibling = state.next_sibling.clone();
-
- let node = new_root.apply(&scope, m, next_sibling, ancestor);
- state.node_ref.link(node);
-
- let first_render = !state.has_rendered;
- state.has_rendered = true;
-
- scheduler::push_component_rendered(
- self.state.as_ptr() as usize,
- RenderedRunner {
- state: self.state.clone(),
- first_render,
- },
- first_render,
- );
- } else {
- #[cfg(feature = "ssr")]
- if let Some(tx) = state.html_sender.take() {
- tx.send(root).unwrap();
- }
- }
+ match state.rendered {
+ Rendered::Render {
+ root_node: ref mut current_root,
+ ref parent,
+ ref next_sibling,
+ ref node_ref,
+ ..
+ } => {
+ let mut root = new_root;
+ std::mem::swap(&mut root, current_root);
+
+ let ancestor = root;
+
+ let node = current_root.apply(&scope, parent, next_sibling.clone(), Some(ancestor));
+
+ node_ref.link(node);
+
+ let first_render = !state.has_rendered;
+ state.has_rendered = true;
+
+ scheduler::push_component_rendered(
+ state.inner.id(),
+ RenderedRunner {
+ state: self.state.clone(),
+ first_render,
+ },
+ first_render,
+ );
+ }
+
+ #[cfg(feature = "hydration")]
+ Rendered::Hydration {
+ ref mut fragment,
+ ref parent,
+ ref node_ref,
+ ref next_sibling,
+ } => {
+ // We schedule a "first" render to run immediately after hydration,
+ // for the following reason:
+ // 1. Fix NodeRef (first_node and next_sibling)
+ // 2. Switch from fallback UI to children UI for component (if it is
+ // not meant to be suspended.).
+ scheduler::push_component_render(
+ state.inner.id(),
+ RenderRunner {
+ state: self.state.clone(),
+ },
+ );
+
+ let mut root = new_root;
+
+ // This first node is not guaranteed to be correct here.
+ // As it may be a comment node that is removed afterwards.
+ // but we link it anyways.
+ let node = root.hydrate(&scope, parent, fragment);
+
+ // We trim all text nodes before checking as it's likely these are whitespaces.
+ fragment.trim_start_text_nodes(parent);
+
+ assert!(fragment.is_empty(), "expected end of component, found node");
+
+ node_ref.link(node);
+
+ state.rendered = Rendered::Render {
+ root_node: root,
+ parent: parent.clone(),
+ node_ref: node_ref.clone(),
+ next_sibling: next_sibling.clone(),
+ };
+ }
+
+ #[cfg(feature = "ssr")]
+ Rendered::Ssr { ref mut sender } => {
+ if let Some(tx) = sender.take() {
+ tx.send(new_root).unwrap();
}
+ }
+ };
+ }
- Err(RenderError::Suspended(m)) => {
- // Currently suspended, we re-use previous root node and send
- // suspension to parent element.
- let shared_state = self.state.clone();
-
- if m.resumed() {
- // schedule a render immediately if suspension is resumed.
-
- scheduler::push_component_render(
- shared_state.as_ptr() as usize,
- RenderRunner {
- state: shared_state.clone(),
- },
- );
- } else {
- // We schedule a render after current suspension is resumed.
-
- let comp_scope = state.inner.any_scope();
-
- let suspense_scope = comp_scope
- .find_parent_scope::()
- .expect("To suspend rendering, a component is required.");
- let suspense = suspense_scope.get_component().unwrap();
-
- m.listen(Callback::from(move |_| {
- scheduler::push_component_render(
- shared_state.as_ptr() as usize,
- RenderRunner {
- state: shared_state.clone(),
- },
- );
- scheduler::start();
- }));
-
- if let Some(ref last_m) = state.suspension {
- if &m != last_m {
- // We remove previous suspension from the suspense.
- suspense.resume(last_m.clone());
- }
- }
- state.suspension = Some(m.clone());
+ fn suspend(&self, state: &mut ComponentState, suspension: Suspension) {
+ // Currently suspended, we re-use previous root node and send
+ // suspension to parent element.
+ let shared_state = self.state.clone();
- suspense.suspend(m);
- }
+ if suspension.resumed() {
+ // schedule a render immediately if suspension is resumed.
+
+ scheduler::push_component_render(
+ state.inner.id(),
+ RenderRunner {
+ state: shared_state,
+ },
+ );
+ } else {
+ // We schedule a render after current suspension is resumed.
+
+ let comp_scope = state.inner.any_scope();
+
+ let suspense_scope = comp_scope
+ .find_parent_scope::()
+ .expect("To suspend rendering, a component is required.");
+ let suspense = suspense_scope.get_component().unwrap();
+
+ let comp_id = state.inner.id();
+
+ suspension.listen(Callback::from(move |_| {
+ scheduler::push_component_render(
+ comp_id,
+ RenderRunner {
+ state: shared_state.clone(),
+ },
+ );
+ scheduler::start();
+ }));
+
+ if let Some(ref last_suspension) = state.suspension {
+ if &suspension != last_suspension {
+ // We remove previous suspension from the suspense.
+ suspense.resume(last_suspension.clone());
}
+ }
+ state.suspension = Some(suspension.clone());
+
+ suspense.suspend(suspension);
+ }
+ }
+}
+
+impl Runnable for RenderRunner {
+ fn run(self: Box) {
+ if let Some(state) = self.state.borrow_mut().as_mut() {
+ #[cfg(debug_assertions)]
+ crate::virtual_dom::vcomp::log_event(state.inner.id(), "render");
+
+ match state.inner.view() {
+ Ok(m) => self.render(state, m),
+ Err(RenderError::Suspended(m)) => self.suspend(state, m),
};
}
}
@@ -381,16 +522,26 @@ impl Runnable for RenderedRunner {
fn run(self: Box) {
if let Some(state) = self.state.borrow_mut().as_mut() {
#[cfg(debug_assertions)]
- crate::virtual_dom::vcomp::log_event(state.vcomp_id, "rendered");
+ crate::virtual_dom::vcomp::log_event(state.inner.id(), "rendered");
- if state.suspension.is_none() && state.parent.is_some() {
- state.inner.rendered(self.first_render);
+ match state.rendered {
+ #[cfg(feature = "ssr")]
+ Rendered::Ssr { .. } => {}
+ #[cfg(feature = "hydration")]
+ Rendered::Hydration { .. } => {}
+
+ // We only call rendered when the component is rendered & not suspended.
+ Rendered::Render { .. } => {
+ if state.suspension.is_none() {
+ state.inner.rendered(self.first_render);
+ }
+ }
}
}
}
}
-#[cfg(test)]
+#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
extern crate self as yew;
diff --git a/packages/yew/src/html/component/marker.rs b/packages/yew/src/html/component/marker.rs
new file mode 100644
index 00000000000..5b7d294db62
--- /dev/null
+++ b/packages/yew/src/html/component/marker.rs
@@ -0,0 +1,167 @@
+//! Primitive Components & Properties Types
+
+use std::marker::PhantomData;
+
+use crate::html;
+use crate::html::{BaseComponent, ChildrenProps, Component, Context, Html};
+
+/// A Component to represent a component that does not exist in current implementation.
+///
+/// During Hydration, Yew expected the Virtual DOM hierarchy to match the the layout used in server-side
+/// rendering. However, sometimes it is possible / reasonable to omit certain components from one
+/// side of the implementation. This component is used to represent a component as if a component "existed"
+/// in the place it is defined.
+///
+/// # Warning
+///
+/// The Real DOM hierarchy must also match the server-side rendered artifact. This component is
+/// only usable when the original component does not introduce any additional elements. (e.g.: Context
+/// Providers)
+///
+/// A generic parameter is provided to help identify the component to be substituted.
+/// The type of the generic parameter is not required to be the same component that was in the other
+/// implementation. However, this behaviour may change in the future if more debug assertions were
+/// to be introduced. It is recommended that the generic parameter represents the component in the
+/// other implementation.
+///
+/// # Example
+///
+/// ```
+/// use yew::prelude::*;
+/// # use yew::html::ChildrenProps;
+/// #
+/// # #[function_component]
+/// # fn Comp(props: &ChildrenProps) -> Html {
+/// # Html::default()
+/// # }
+/// #
+/// # #[function_component]
+/// # fn Provider(props: &ChildrenProps) -> Html {
+/// # let children = props.children.clone();
+/// #
+/// # html! { <>{children}> }
+/// # }
+/// # type Provider1 = Provider;
+/// # type Provider2 = Provider;
+/// # type Provider3 = Provider;
+/// # type Provider4 = Provider;
+///
+/// #[function_component]
+/// fn ServerApp() -> Html {
+/// // The Server Side Rendering Application has 3 Providers.
+/// html! {
+///
+///
+///
+///
+///
+///
+///
+/// }
+/// }
+///
+/// #[function_component]
+/// fn App() -> Html {
+/// // The Client Side Rendering Application has 4 Providers.
+/// html! {
+///
+///
+///
+///
+/// // This provider does not exist on the server-side
+/// // Hydration will fail due to Virtual DOM layout mismatch.
+///
+///
+///
+///
+///
+///
+///
+/// }
+/// }
+/// ```
+///
+/// To mitigate this, we can use a `PhantomComponent`:
+///
+/// ```
+/// use yew::prelude::*;
+/// # use yew::html::{PhantomComponent, ChildrenProps};
+/// #
+/// # #[function_component]
+/// # fn Comp(props: &ChildrenProps) -> Html {
+/// # Html::default()
+/// # }
+/// #
+/// # #[function_component]
+/// # fn Provider(props: &ChildrenProps) -> Html {
+/// # let children = props.children.clone();
+/// #
+/// # html! { <>{children}> }
+/// # }
+/// # type Provider1 = Provider;
+/// # type Provider2 = Provider;
+/// # type Provider3 = Provider;
+/// # type Provider4 = Provider;
+///
+/// #[function_component]
+/// fn ServerApp() -> Html {
+/// html! {
+///
+///
+///
+/// // We add a PhantomComponent for Provider4,
+/// // it acts if a Provider4 component presents in this position.
+/// >
+///
+/// >
+///
+///
+///
+/// }
+/// }
+///
+/// #[function_component]
+/// fn App() -> Html {
+/// html! {
+///
+///
+///
+///
+/// // Hydration will succeed as the PhantomComponent in the server-side
+/// // implementation will represent a Provider4 component in this position.
+///
+///
+///
+///
+///
+///
+///
+/// }
+/// }
+/// ```
+#[derive(Debug)]
+pub struct PhantomComponent
+where
+ T: BaseComponent + 'static,
+{
+ _marker: PhantomData,
+}
+
+impl Component for PhantomComponent
+where
+ T: BaseComponent + 'static,
+{
+ type Properties = ChildrenProps;
+ type Message = ();
+
+ fn create(_ctx: &Context) -> Self {
+ Self {
+ _marker: PhantomData,
+ }
+ }
+
+ fn view(&self, ctx: &Context) -> Html {
+ let children = ctx.props().children.clone();
+ html! { <>{children}> }
+ }
+}
diff --git a/packages/yew/src/html/component/mod.rs b/packages/yew/src/html/component/mod.rs
index 3528c7c9470..2f056b2c16e 100644
--- a/packages/yew/src/html/component/mod.rs
+++ b/packages/yew/src/html/component/mod.rs
@@ -2,11 +2,13 @@
mod children;
mod lifecycle;
+mod marker;
mod properties;
mod scope;
use super::{Html, HtmlResult, IntoHtmlResult};
pub use children::*;
+pub use marker::*;
pub use properties::*;
pub(crate) use scope::Scoped;
pub use scope::{AnyScope, Scope, SendAsMessage};
diff --git a/packages/yew/src/html/component/scope.rs b/packages/yew/src/html/component/scope.rs
index d93f48586d6..872501af7c5 100644
--- a/packages/yew/src/html/component/scope.rs
+++ b/packages/yew/src/html/component/scope.rs
@@ -1,17 +1,15 @@
//! Component scope module
-use super::{
- lifecycle::{
- CompStateInner, ComponentState, CreateRunner, DestroyRunner, RenderRunner, UpdateEvent,
- UpdateRunner,
- },
- BaseComponent,
+use super::lifecycle::{
+ CompStateInner, ComponentState, CreateRunner, DestroyRunner, RenderRunner, Rendered,
+ UpdateEvent, UpdateRunner,
};
+use super::BaseComponent;
use crate::callback::Callback;
use crate::context::{ContextHandle, ContextProvider};
use crate::html::NodeRef;
use crate::scheduler::{self, Shared};
-use crate::virtual_dom::{insert_node, VNode};
+use crate::virtual_dom::{insert_node, VComp, VNode};
use gloo_utils::document;
use std::any::TypeId;
use std::cell::{Ref, RefCell};
@@ -65,9 +63,6 @@ pub struct AnyScope {
type_id: TypeId,
parent: Option>,
state: Shared>,
-
- #[cfg(debug_assertions)]
- pub(crate) vcomp_id: usize,
}
impl fmt::Debug for AnyScope {
@@ -82,23 +77,17 @@ impl From> for AnyScope {
type_id: TypeId::of::(),
parent: scope.parent,
state: scope.state,
-
- #[cfg(debug_assertions)]
- vcomp_id: scope.vcomp_id,
}
}
}
impl AnyScope {
- #[cfg(test)]
+ #[cfg(all(test, target_arch = "wasm32"))]
pub(crate) fn test() -> Self {
Self {
type_id: TypeId::of::<()>(),
parent: None,
state: Rc::new(RefCell::new(None)),
-
- #[cfg(debug_assertions)]
- vcomp_id: 0,
}
}
@@ -171,7 +160,12 @@ impl Scoped for Scope {
state_ref.as_ref()?;
Some(Ref::map(state_ref, |state_ref| {
- &state_ref.as_ref().unwrap().root_node
+ state_ref
+ .as_ref()
+ .unwrap()
+ .rendered
+ .root_vnode()
+ .unwrap_or(VNode::EMPTY)
}))
}
@@ -200,8 +194,7 @@ pub struct Scope {
pub(crate) pending_messages: MsgQueue,
pub(crate) state: Shared>,
- #[cfg(debug_assertions)]
- pub(crate) vcomp_id: usize,
+ pub(crate) id: usize,
}
impl fmt::Debug for Scope {
@@ -218,8 +211,7 @@ impl Clone for Scope {
parent: self.parent.clone(),
state: self.state.clone(),
- #[cfg(debug_assertions)]
- vcomp_id: self.vcomp_id,
+ id: self.id,
}
}
}
@@ -252,17 +244,13 @@ impl Scope {
let state = Rc::new(RefCell::new(None));
let pending_messages = MsgQueue::new();
- #[cfg(debug_assertions)]
- let vcomp_id = parent.as_ref().map(|p| p.vcomp_id).unwrap_or_default();
-
Scope {
_marker: PhantomData,
pending_messages,
state,
parent,
- #[cfg(debug_assertions)]
- vcomp_id,
+ id: VComp::next_id(),
}
}
@@ -275,7 +263,7 @@ impl Scope {
props: Rc,
) {
#[cfg(debug_assertions)]
- crate::virtual_dom::vcomp::log_event(self.vcomp_id, "create placeholder");
+ crate::virtual_dom::vcomp::log_event(self.id, "create placeholder");
let placeholder = {
let placeholder: Node = document().create_text_node("").into();
insert_node(&placeholder, &parent, next_sibling.get().as_ref());
@@ -283,21 +271,25 @@ impl Scope {
VNode::VRef(placeholder)
};
+ let rendered = Rendered::Render {
+ root_node: placeholder,
+ node_ref,
+ next_sibling,
+ parent,
+ };
+
scheduler::push_component_create(
+ self.id,
CreateRunner {
- parent: Some(parent),
- next_sibling,
- placeholder,
- node_ref,
+ rendered,
props,
scope: self.clone(),
- #[cfg(feature = "ssr")]
- html_sender: None,
},
RenderRunner {
state: self.state.clone(),
},
);
+
// Not guaranteed to already have the scheduler started
scheduler::start();
}
@@ -309,7 +301,7 @@ impl Scope {
next_sibling: NodeRef,
) {
#[cfg(debug_assertions)]
- crate::virtual_dom::vcomp::log_event(self.vcomp_id, "reuse");
+ crate::virtual_dom::vcomp::log_event(self.id, "reuse");
self.push_update(UpdateEvent::Properties(props, node_ref, next_sibling));
}
@@ -399,18 +391,22 @@ mod feat_ssr {
use futures::channel::oneshot;
impl Scope {
- pub(crate) async fn render_to_string(&self, w: &mut String, props: Rc) {
+ pub(crate) async fn render_to_string(
+ &self,
+ w: &mut String,
+ props: Rc,
+ hydratable: bool,
+ ) {
let (tx, rx) = oneshot::channel();
+ let rendered = Rendered::Ssr { sender: Some(tx) };
+
scheduler::push_component_create(
+ self.id,
CreateRunner {
- parent: None,
- next_sibling: NodeRef::default(),
- placeholder: VNode::default(),
- node_ref: NodeRef::default(),
+ rendered,
props,
scope: self.clone(),
- html_sender: Some(tx),
},
RenderRunner {
state: self.state.clone(),
@@ -418,15 +414,32 @@ mod feat_ssr {
);
scheduler::start();
+ if hydratable {
+ #[cfg(debug_assertions)]
+ w.push_str(&format!("", std::any::type_name::()));
+
+ #[cfg(not(debug_assertions))]
+ w.push_str("");
+ }
+
let html = rx.await.unwrap();
let self_any_scope = self.to_any();
- html.render_to_string(w, &self_any_scope).await;
+ html.render_to_string(w, &self_any_scope, hydratable).await;
+
+ if hydratable {
+ #[cfg(debug_assertions)]
+ w.push_str(&format!("", std::any::type_name::()));
+
+ #[cfg(not(debug_assertions))]
+ w.push_str("");
+ }
scheduler::push_component_destroy(DestroyRunner {
state: self.state.clone(),
parent_to_detach: false,
});
+
scheduler::start();
}
}
@@ -498,6 +511,72 @@ mod feat_io {
}
}
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use crate::virtual_dom::Fragment;
+
+ impl Scope {
+ /// Hydrates the component.
+ ///
+ /// Returns a pending NodeRef of the next sibling.
+ ///
+ /// # Note
+ ///
+ /// This method is expected to collect all the elements belongs to the current component
+ /// immediately.
+ ///
+ /// We don't remove the comment node at the moment as it's needed to maintain the
+ /// structure.
+ pub(crate) fn hydrate_in_place(
+ &self,
+ parent: Element,
+ fragment: &mut Fragment,
+ node_ref: NodeRef,
+ props: Rc,
+ ) {
+ // This is very helpful to see which component is failing during hydration
+ // which means this component may not having a stable layout / differs between
+ // client-side and server-side.
+ #[cfg(all(debug_assertions, feature = "trace_hydration"))]
+ gloo::console::trace!(
+ "queuing hydration of: {}(ID: {})",
+ std::any::type_name::(),
+ self.id
+ );
+
+ let fragment =
+ Fragment::collect_between(fragment, &parent, "<[", "[", "]>", "component");
+ node_ref.set(fragment.front().cloned());
+ let next_sibling = NodeRef::default();
+
+ let rendered = Rendered::Hydration {
+ parent,
+ node_ref,
+ next_sibling,
+ fragment,
+ };
+
+ scheduler::push_component_create(
+ self.id,
+ CreateRunner {
+ rendered,
+ props,
+ scope: self.clone(),
+ },
+ RenderRunner {
+ state: self.state.clone(),
+ },
+ );
+
+ // Not guaranteed to already have the scheduler started
+ scheduler::start();
+ }
+ }
+}
+
/// Defines a message type that can be sent to a component.
/// Used for the return value of closure given to [Scope::batch_callback](struct.Scope.html#method.batch_callback).
pub trait SendAsMessage {
diff --git a/packages/yew/src/html/mod.rs b/packages/yew/src/html/mod.rs
index ae290b1e67f..1ddf0108056 100644
--- a/packages/yew/src/html/mod.rs
+++ b/packages/yew/src/html/mod.rs
@@ -172,7 +172,7 @@ pub fn create_portal(child: Html, host: Element) -> Html {
VNode::VPortal(VPortal::new(child, host))
}
-#[cfg(test)]
+#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
use super::*;
use gloo_utils::document;
diff --git a/packages/yew/src/lib.rs b/packages/yew/src/lib.rs
index e0885153ca5..c134d6f8386 100644
--- a/packages/yew/src/lib.rs
+++ b/packages/yew/src/lib.rs
@@ -22,6 +22,9 @@
//! - `tokio`: Enables future-based APIs on non-wasm32 targets with tokio runtime. (You may want to
//! enable this if your application uses future-based APIs and it does not compile / lint on
//! non-wasm32 targets.)
+//! - `hydration`: Enables Hydration support.
+//! - `trace_hydration`: Enables trace logging on hydration. (Implies `hydration`. You may want to enable this if you are
+//! trying to debug hydration layout mismatch.)
//!
//! ## Example
//!
@@ -84,8 +87,6 @@
#![recursion_limit = "512"]
extern crate self as yew;
-use std::{cell::Cell, panic::PanicInfo};
-
/// This macro provides a convenient way to create [`Classes`].
///
/// The macro takes a list of items similar to the [`vec!`] macro and returns a [`Classes`] instance.
@@ -269,6 +270,7 @@ pub mod context;
pub mod functional;
pub mod html;
mod io_coop;
+mod renderer;
pub mod scheduler;
mod sealed;
#[cfg(feature = "ssr")]
@@ -295,75 +297,8 @@ pub mod events {
}
pub use crate::app_handle::AppHandle;
-use web_sys::Element;
-
-use crate::html::BaseComponent;
-
-thread_local! {
- static PANIC_HOOK_IS_SET: Cell = Cell::new(false);
-}
-
-/// Set a custom panic hook.
-/// Unless a panic hook is set through this function, Yew will
-/// overwrite any existing panic hook when one of the `start_app*` functions are called.
-pub fn set_custom_panic_hook(hook: Box) + Sync + Send + 'static>) {
- std::panic::set_hook(hook);
- PANIC_HOOK_IS_SET.with(|hook_is_set| hook_is_set.set(true));
-}
-
-fn set_default_panic_hook() {
- if !PANIC_HOOK_IS_SET.with(|hook_is_set| hook_is_set.replace(true)) {
- std::panic::set_hook(Box::new(console_error_panic_hook::hook));
- }
-}
-/// The main entry point of a Yew application.
-/// If you would like to pass props, use the `start_app_with_props_in_element` method.
-pub fn start_app_in_element(element: Element) -> AppHandle
-where
- COMP: BaseComponent,
- COMP::Properties: Default,
-{
- start_app_with_props_in_element(element, COMP::Properties::default())
-}
-
-/// Starts an yew app mounted to the body of the document.
-/// Alias to start_app_in_element(Body)
-pub fn start_app() -> AppHandle
-where
- COMP: BaseComponent,
- COMP::Properties: Default,
-{
- start_app_with_props(COMP::Properties::default())
-}
-
-/// The main entry point of a Yew application. This function does the
-/// same as `start_app_in_element(...)` but allows to start an Yew application with properties.
-pub fn start_app_with_props_in_element(
- element: Element,
- props: COMP::Properties,
-) -> AppHandle
-where
- COMP: BaseComponent,
-{
- set_default_panic_hook();
- AppHandle::::mount_with_props(element, Rc::new(props))
-}
-
-/// The main entry point of a Yew application.
-/// This function does the same as `start_app(...)` but allows to start an Yew application with properties.
-pub fn start_app_with_props(props: COMP::Properties) -> AppHandle
-where
- COMP: BaseComponent,
-{
- start_app_with_props_in_element(
- gloo_utils::document()
- .body()
- .expect("no body node found")
- .into(),
- props,
- )
-}
+pub use renderer::*;
/// The Yew Prelude
///
@@ -379,14 +314,14 @@ pub mod prelude {
pub use crate::context::{ContextHandle, ContextProvider};
pub use crate::events::*;
pub use crate::html::{
- create_portal, BaseComponent, Children, ChildrenWithProps, Classes, Component, Context,
- Html, HtmlResult, NodeRef, Properties,
+ create_portal, BaseComponent, Children, ChildrenProps, ChildrenWithProps, Classes,
+ Component, Context, Html, HtmlResult, NodeRef, PhantomComponent, Properties,
};
pub use crate::macros::{classes, html, html_nested};
pub use crate::suspense::Suspense;
pub use crate::functional::*;
+ pub use crate::renderer::*;
}
pub use self::prelude::*;
-use std::rc::Rc;
diff --git a/packages/yew/src/renderer.rs b/packages/yew/src/renderer.rs
new file mode 100644
index 00000000000..4f5db36a438
--- /dev/null
+++ b/packages/yew/src/renderer.rs
@@ -0,0 +1,149 @@
+use std::cell::Cell;
+use std::panic::PanicInfo;
+use std::rc::Rc;
+
+use web_sys::Element;
+
+use crate::app_handle::AppHandle;
+use crate::html::BaseComponent;
+
+thread_local! {
+ static PANIC_HOOK_IS_SET: Cell = Cell::new(false);
+}
+
+/// Set a custom panic hook.
+/// Unless a panic hook is set through this function, Yew will
+/// overwrite any existing panic hook when one of the `start_app*` functions are called.
+pub fn set_custom_panic_hook(hook: Box) + Sync + Send + 'static>) {
+ std::panic::set_hook(hook);
+ PANIC_HOOK_IS_SET.with(|hook_is_set| hook_is_set.set(true));
+}
+
+fn set_default_panic_hook() {
+ if !PANIC_HOOK_IS_SET.with(|hook_is_set| hook_is_set.replace(true)) {
+ std::panic::set_hook(Box::new(console_error_panic_hook::hook));
+ }
+}
+
+/// The Yew Renderer.
+///
+/// This is the main entry point of a Yew application.
+#[derive(Debug)]
+pub struct Renderer
+where
+ COMP: BaseComponent + 'static,
+{
+ root: Element,
+ props: COMP::Properties,
+}
+
+impl Default for Renderer
+where
+ COMP: BaseComponent + 'static,
+ COMP::Properties: Default,
+{
+ fn default() -> Self {
+ Self::with_props(Default::default())
+ }
+}
+
+impl Renderer
+where
+ COMP: BaseComponent + 'static,
+ COMP::Properties: Default,
+{
+ /// Creates a [Renderer] that renders into the document body with default properties.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Creates a [Renderer] that renders into a custom root with default properties.
+ pub fn with_root(root: Element) -> Self {
+ Self::with_root_and_props(root, Default::default())
+ }
+}
+
+impl Renderer
+where
+ COMP: BaseComponent + 'static,
+{
+ /// Creates a [Renderer] that renders into the document body with custom properties.
+ pub fn with_props(props: COMP::Properties) -> Self {
+ Self::with_root_and_props(
+ gloo_utils::document()
+ .body()
+ .expect("no body node found")
+ .into(),
+ props,
+ )
+ }
+
+ /// Creates a [Renderer] that renders into a custom root with custom properties.
+ pub fn with_root_and_props(root: Element, props: COMP::Properties) -> Self {
+ Self { root, props }
+ }
+
+ /// Renders the application.
+ pub fn render(self) -> AppHandle {
+ set_default_panic_hook();
+ AppHandle::::mount_with_props(self.root, Rc::new(self.props))
+ }
+}
+
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ impl Renderer
+ where
+ COMP: BaseComponent + 'static,
+ {
+ /// Hydrates the application.
+ pub fn hydrate(self) -> AppHandle {
+ set_default_panic_hook();
+ AppHandle::::hydrate_with_props(self.root, Rc::new(self.props))
+ }
+ }
+}
+
+/// The main entry point of a Yew application.
+/// If you would like to pass props, use the `start_app_with_props_in_element` method.
+pub fn start_app_in_element(element: Element) -> AppHandle
+where
+ COMP: BaseComponent,
+ COMP::Properties: Default,
+{
+ Renderer::with_root(element).render()
+}
+
+/// Starts an yew app mounted to the body of the document.
+/// Alias to start_app_in_element(Body)
+pub fn start_app() -> AppHandle
+where
+ COMP: BaseComponent,
+ COMP::Properties: Default,
+{
+ Renderer::new().render()
+}
+
+/// The main entry point of a Yew application. This function does the
+/// same as `start_app_in_element(...)` but allows to start an Yew application with properties.
+pub fn start_app_with_props_in_element(
+ element: Element,
+ props: COMP::Properties,
+) -> AppHandle
+where
+ COMP: BaseComponent,
+{
+ Renderer::with_root_and_props(element, props).render()
+}
+
+/// The main entry point of a Yew application.
+/// This function does the same as `start_app(...)` but allows to start an Yew application with properties.
+pub fn start_app_with_props(props: COMP::Properties) -> AppHandle
+where
+ COMP: BaseComponent,
+{
+ Renderer::with_props(props).render()
+}
diff --git a/packages/yew/src/scheduler.rs b/packages/yew/src/scheduler.rs
index ef888d71d44..de633b86be7 100644
--- a/packages/yew/src/scheduler.rs
+++ b/packages/yew/src/scheduler.rs
@@ -1,7 +1,7 @@
//! This module contains a scheduler.
use std::cell::RefCell;
-use std::collections::{hash_map::Entry, HashMap, VecDeque};
+use std::collections::BTreeMap;
use std::rc::Rc;
/// Alias for Rc>
@@ -24,12 +24,18 @@ struct Scheduler {
destroy: Vec>,
create: Vec>,
update: Vec>,
- render_first: VecDeque>,
- render: RenderScheduler,
- /// Stacks to ensure child calls are always before parent calls
- rendered_first: Vec>,
- rendered: RenderedScheduler,
+ /// A Binary Tree Map here guarantees components with lower id (parent) is rendered first and
+ /// no more than 1 render can be scheduled before a component is rendered.
+ ///
+ /// Parent can destroy child components but not otherwise, we can save unnecessary render by
+ /// rendering parent first.
+ render_first: BTreeMap>,
+ render: BTreeMap>,
+
+ /// Binary Tree Map to guarantee children rendered are always called before parent calls
+ rendered_first: BTreeMap>,
+ rendered: BTreeMap>,
}
/// Execute closure with a mutable reference to the scheduler
@@ -54,14 +60,15 @@ pub fn push(runnable: Box) {
start();
}
-/// Push a component creation, first render and first rendered [Runnable]s to be executed
+/// Push a component creation and first render [Runnable]s to be executed
pub(crate) fn push_component_create(
+ component_id: usize,
create: impl Runnable + 'static,
first_render: impl Runnable + 'static,
) {
with(|s| {
s.create.push(Box::new(create));
- s.render_first.push_back(Box::new(first_render));
+ s.render_first.insert(component_id, Box::new(first_render));
});
}
@@ -73,7 +80,7 @@ pub(crate) fn push_component_destroy(runnable: impl Runnable + 'static) {
/// Push a component render and rendered [Runnable]s to be executed
pub(crate) fn push_component_render(component_id: usize, render: impl Runnable + 'static) {
with(|s| {
- s.render.schedule(component_id, Box::new(render));
+ s.render.insert(component_id, Box::new(render));
});
}
@@ -86,9 +93,9 @@ pub(crate) fn push_component_rendered(
let rendered = Box::new(rendered);
if first_render {
- s.rendered_first.push(rendered);
+ s.rendered_first.insert(component_id, rendered);
} else {
- s.rendered.schedule(component_id, rendered);
+ s.rendered.insert(component_id, rendered);
}
});
}
@@ -170,12 +177,26 @@ impl Scheduler {
// Create events can be batched, as they are typically just for object creation
to_run.append(&mut self.create);
+ // These typically do nothing and don't spawn any other events - can be batched.
+ // Should be run only after all first renders have finished.
+ if !to_run.is_empty() {
+ return;
+ }
+
// First render must never be skipped and takes priority over main, because it may need
// to init `NodeRef`s
//
// Should be processed one at time, because they can spawn more create and rendered events
// for their children.
- if let Some(r) = self.render_first.pop_front() {
+ //
+ // To be replaced with BTreeMap::pop_front once it is stable.
+ if let Some(r) = self
+ .render_first
+ .keys()
+ .next()
+ .cloned()
+ .and_then(|m| self.render_first.remove(&m))
+ {
to_run.push(r);
}
@@ -184,7 +205,12 @@ impl Scheduler {
if !to_run.is_empty() {
return;
}
- to_run.extend(self.rendered_first.drain(..).rev());
+
+ if !self.rendered_first.is_empty() {
+ let mut rendered_first = BTreeMap::new();
+ std::mem::swap(&mut self.rendered_first, &mut rendered_first);
+ to_run.extend(rendered_first.into_values().rev());
+ }
// Updates are after the first render to ensure we always have the entire child tree
// rendered, once an update is processed.
@@ -202,7 +228,17 @@ impl Scheduler {
if !to_run.is_empty() {
return;
}
- if let Some(r) = self.render.pop() {
+
+ // To be replaced with BTreeMap::pop_front once it is stable.
+ // Should be processed one at time, because they can spawn more create and rendered events
+ // for their children.
+ if let Some(r) = self
+ .render
+ .keys()
+ .next()
+ .cloned()
+ .and_then(|m| self.render.remove(&m))
+ {
to_run.push(r);
}
@@ -211,91 +247,11 @@ impl Scheduler {
if !to_run.is_empty() {
return;
}
- self.rendered.drain_into(to_run);
- }
-}
-
-/// Task to be executed for specific component
-struct QueueTask {
- /// Tasks in the queue to skip for this component
- skip: usize,
-
- /// Runnable to execute
- runnable: Box,
-}
-
-/// Scheduler for non-first component renders with deduplication
-#[derive(Default)]
-struct RenderScheduler {
- /// Task registry by component ID
- tasks: HashMap,
-
- /// Task queue by component ID
- queue: VecDeque,
-}
-
-impl RenderScheduler {
- /// Schedule render task execution
- fn schedule(&mut self, component_id: usize, runnable: Box) {
- self.queue.push_back(component_id);
- match self.tasks.entry(component_id) {
- Entry::Vacant(e) => {
- e.insert(QueueTask { skip: 0, runnable });
- }
- Entry::Occupied(mut e) => {
- let v = e.get_mut();
- v.skip += 1;
-
- // Technically the 2 runners should be functionally identical, but might as well
- // overwrite it for good measure, accounting for future changes. We have it here
- // anyway.
- v.runnable = runnable;
- }
- }
- }
- /// Try to pop a task from the queue, if any
- fn pop(&mut self) -> Option> {
- while let Some(id) = self.queue.pop_front() {
- match self.tasks.entry(id) {
- Entry::Occupied(mut e) => {
- let v = e.get_mut();
- if v.skip == 0 {
- return Some(e.remove().runnable);
- }
- v.skip -= 1;
- }
- Entry::Vacant(_) => (),
- }
- }
- None
- }
-}
-
-/// Deduplicating scheduler for component rendered calls with deduplication
-#[derive(Default)]
-struct RenderedScheduler {
- /// Task registry by component ID
- tasks: HashMap>,
-
- /// Task stack by component ID
- stack: Vec,
-}
-
-impl RenderedScheduler {
- /// Schedule rendered task execution
- fn schedule(&mut self, component_id: usize, runnable: Box) {
- if self.tasks.insert(component_id, runnable).is_none() {
- self.stack.push(component_id);
- }
- }
-
- /// Drain all tasks into `dst`, if any
- fn drain_into(&mut self, dst: &mut Vec>) {
- for id in self.stack.drain(..).rev() {
- if let Some(t) = self.tasks.remove(&id) {
- dst.push(t);
- }
+ if !self.rendered.is_empty() {
+ let mut rendered = BTreeMap::new();
+ std::mem::swap(&mut self.rendered, &mut rendered);
+ to_run.extend(rendered.into_values().rev());
}
}
}
diff --git a/packages/yew/src/server_renderer.rs b/packages/yew/src/server_renderer.rs
index 9e5cd5fe1cb..fa1b2fdd689 100644
--- a/packages/yew/src/server_renderer.rs
+++ b/packages/yew/src/server_renderer.rs
@@ -10,6 +10,7 @@ where
COMP: BaseComponent,
{
props: COMP::Properties,
+ hydratable: bool,
}
impl Default for ServerRenderer
@@ -39,7 +40,20 @@ where
{
/// Creates a [ServerRenderer] with custom properties.
pub fn with_props(props: COMP::Properties) -> Self {
- Self { props }
+ Self {
+ props,
+ hydratable: true,
+ }
+ }
+
+ /// Sets whether an the rendered result is hydratable.
+ ///
+ /// Defaults to `true`.
+ ///
+ /// When this is sets to `true`, the rendered artifact will include assistive nodes
+ /// to assist with the hydration process.
+ pub fn set_hydratable(&mut self, val: bool) {
+ self.hydratable = val;
}
/// Renders Yew Application.
@@ -51,9 +65,11 @@ where
s
}
- /// Renders Yew Application to a String.
+ /// Renders Yew Application into the provided String.
pub async fn render_to_string(self, w: &mut String) {
let scope = Scope::::new(None);
- scope.render_to_string(w, self.props.into()).await;
+ scope
+ .render_to_string(w, self.props.into(), self.hydratable)
+ .await;
}
}
diff --git a/packages/yew/src/suspense/component.rs b/packages/yew/src/suspense/component.rs
index 66e35422a9a..d0da4da9586 100644
--- a/packages/yew/src/suspense/component.rs
+++ b/packages/yew/src/suspense/component.rs
@@ -1,45 +1,42 @@
+use crate::html;
use crate::html::{Children, Component, Context, Html, Properties, Scope};
-use crate::virtual_dom::{Key, VList, VNode, VSuspense};
+use crate::virtual_dom::{VList, VNode, VSuspense};
use web_sys::Element;
use super::Suspension;
#[derive(Properties, PartialEq, Debug, Clone)]
-pub struct SuspenseProps {
- #[prop_or_default]
+pub(crate) struct BaseSuspenseProps {
pub children: Children,
- #[prop_or_default]
pub fallback: Html,
- #[prop_or_default]
- pub key: Option,
+ pub suspendible: bool,
}
#[derive(Debug)]
-pub enum SuspenseMsg {
+pub(crate) enum BaseSuspenseMsg {
Suspend(Suspension),
Resume(Suspension),
}
-/// Suspend rendering and show a fallback UI until the underlying task completes.
+/// The Implementation of Suspense Component.
#[derive(Debug)]
-pub struct Suspense {
+pub(crate) struct BaseSuspense {
link: Scope,
suspensions: Vec,
detached_parent: Option,
}
-impl Component for Suspense {
- type Properties = SuspenseProps;
- type Message = SuspenseMsg;
+impl Component for BaseSuspense {
+ type Properties = BaseSuspenseProps;
+ type Message = BaseSuspenseMsg;
fn create(ctx: &Context) -> Self {
Self {
link: ctx.link().clone(),
suspensions: Vec::new(),
-
#[cfg(target_arch = "wasm32")]
detached_parent: web_sys::window()
.and_then(|m| m.document())
@@ -50,9 +47,14 @@ impl Component for Suspense {
}
}
- fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool {
+ fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool {
match msg {
Self::Message::Suspend(m) => {
+ assert!(
+ ctx.props().suspendible,
+ "You cannot suspend from a component rendered as a fallback."
+ );
+
if m.resumed() {
return false;
}
@@ -73,33 +75,66 @@ impl Component for Suspense {
}
fn view(&self, ctx: &Context) -> Html {
- let SuspenseProps {
- children,
- fallback: fallback_vnode,
- key,
+ let BaseSuspenseProps {
+ children, fallback, ..
} = (*ctx.props()).clone();
- let children_vnode =
- VNode::from(VList::with_children(children.into_iter().collect(), None));
+ if ctx.props().suspendible {
+ let children = VNode::from(VList::with_children(children.into_iter().collect(), None));
+ let fallback = (!self.suspensions.is_empty()).then(|| fallback);
- let vsuspense = VSuspense::new(
- children_vnode,
- fallback_vnode,
- self.detached_parent.clone(),
- !self.suspensions.is_empty(),
- key,
- );
-
- VNode::from(vsuspense)
+ let vsuspense = VSuspense::new(children, fallback, self.detached_parent.clone());
+ VNode::from(vsuspense)
+ } else {
+ html! {<>{children}>}
+ }
}
}
-impl Suspense {
+impl BaseSuspense {
pub(crate) fn suspend(&self, s: Suspension) {
- self.link.send_message(SuspenseMsg::Suspend(s));
+ self.link.send_message(BaseSuspenseMsg::Suspend(s));
}
pub(crate) fn resume(&self, s: Suspension) {
- self.link.send_message(SuspenseMsg::Resume(s));
+ self.link.send_message(BaseSuspenseMsg::Resume(s));
+ }
+}
+
+#[derive(Properties, PartialEq, Debug, Clone)]
+pub struct SuspenseProps {
+ #[prop_or_default]
+ pub children: Children,
+
+ #[prop_or_default]
+ pub fallback: Html,
+}
+
+/// Suspend rendering and show a fallback UI until the underlying task completes.
+#[derive(Debug)]
+pub struct Suspense {}
+
+impl Component for Suspense {
+ type Properties = SuspenseProps;
+ type Message = ();
+
+ fn create(_ctx: &Context) -> Self {
+ Self {}
+ }
+
+ fn view(&self, ctx: &Context) -> Html {
+ let SuspenseProps { children, fallback } = ctx.props().clone();
+
+ let fallback = html! {
+
+ {fallback}
+
+ };
+
+ html! {
+
+ {children}
+
+ }
}
}
diff --git a/packages/yew/src/suspense/mod.rs b/packages/yew/src/suspense/mod.rs
index 617c263775f..e720539fa22 100644
--- a/packages/yew/src/suspense/mod.rs
+++ b/packages/yew/src/suspense/mod.rs
@@ -3,5 +3,6 @@
mod component;
mod suspension;
+pub(crate) use component::BaseSuspense;
pub use component::Suspense;
pub use suspension::{Suspension, SuspensionHandle, SuspensionResult};
diff --git a/packages/yew/src/virtual_dom/mod.rs b/packages/yew/src/virtual_dom/mod.rs
index 94e637c57cd..363a97af2d2 100644
--- a/packages/yew/src/virtual_dom/mod.rs
+++ b/packages/yew/src/virtual_dom/mod.rs
@@ -211,7 +211,7 @@ trait Apply {
}
/// A collection of attributes for an element
-#[derive(PartialEq, Eq, Clone, Debug)]
+#[derive(Clone, Debug)]
pub enum Attributes {
/// Static list of attributes.
///
@@ -237,6 +237,17 @@ pub enum Attributes {
IndexMap(IndexMap<&'static str, AttrValue>),
}
+impl PartialEq for Attributes {
+ fn eq(&self, rhs: &Self) -> bool {
+ self.iter()
+ .zip(rhs.iter())
+ .try_for_each(|(l, r)| if l == r { Ok(()) } else { Err(()) })
+ .is_ok()
+ }
+}
+
+impl Eq for Attributes {}
+
impl Attributes {
/// Construct a default Attributes instance
pub fn new() -> Self {
@@ -541,6 +552,187 @@ pub(crate) trait VDiff {
) -> NodeRef;
}
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use std::collections::VecDeque;
+ use std::ops::{Deref, DerefMut};
+
+ /// This trait provides features to hydrate a fragment.
+ pub(crate) trait VHydrate {
+ /// hydrates current tree.
+ ///
+ /// Returns a reference to the first node of the hydrated tree.
+ ///
+ /// # Important
+ ///
+ /// DOM tree is hydrated from top to bottom. This is different than VDiff::apply.
+ fn hydrate(
+ &mut self,
+ parent_scope: &AnyScope,
+ parent: &Element,
+ fragment: &mut Fragment,
+ ) -> NodeRef;
+ }
+
+ /// A Hydration Fragment
+ #[derive(Default, Debug, Clone, PartialEq, Eq)]
+ pub(crate) struct Fragment(VecDeque);
+
+ impl Deref for Fragment {
+ type Target = VecDeque;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+ }
+
+ impl DerefMut for Fragment {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+ }
+
+ impl Fragment {
+ /// Collects child nodes of an element into a VecDeque.
+ pub(crate) fn collect_children(parent: &Element) -> Self {
+ let mut fragment = VecDeque::with_capacity(parent.child_nodes().length() as usize);
+
+ let mut current_node = parent.first_child();
+
+ // This is easier than iterating child nodes at the moment
+ // as we don't have to downcast iterator values.
+ while let Some(m) = current_node {
+ current_node = m.next_sibling();
+ fragment.push_back(m);
+ }
+
+ Self(fragment)
+ }
+
+ /// VDiff::shift, but for fragments
+ pub(crate) fn shift(
+ &self,
+ previous_parent: &Element,
+ next_parent: &Element,
+ next_sibling: NodeRef,
+ ) {
+ for node in self.iter() {
+ previous_parent.remove_child(node).unwrap();
+ next_parent
+ .insert_before(node, next_sibling.get().as_ref())
+ .unwrap();
+ }
+ }
+
+ /// Collects nodes for a Component or a Suspense Boundary.
+ pub(crate) fn collect_between(
+ collect_from: &mut Fragment,
+ parent: &Element,
+ open_start_mark: &str,
+ close_start_mark: &str,
+ end_mark: &str,
+ kind_name: &str,
+ ) -> Self {
+ let is_open_tag = |node: &Node| {
+ let comment_text = node.text_content().unwrap_or_else(|| "".to_string());
+
+ comment_text.starts_with(&open_start_mark) && comment_text.ends_with(&end_mark)
+ };
+
+ let is_close_tag = |node: &Node| {
+ let comment_text = node.text_content().unwrap_or_else(|| "".to_string());
+
+ comment_text.starts_with(&close_start_mark) && comment_text.ends_with(&end_mark)
+ };
+
+ // We trim all leading text nodes as it's likely these are whitespaces.
+ collect_from.trim_start_text_nodes(parent);
+
+ let first_node = collect_from
+ .pop_front()
+ .unwrap_or_else(|| panic!("expected {} opening tag, found EOF", kind_name));
+
+ assert_eq!(
+ first_node.node_type(),
+ Node::COMMENT_NODE,
+ // TODO: improve error message with human readable node type name.
+ "expected {} start, found node type {}",
+ kind_name,
+ first_node.node_type()
+ );
+
+ let mut nodes = VecDeque::new();
+
+ if !is_open_tag(&first_node) {
+ panic!("expected {} opening tag, found comment node", kind_name);
+ }
+
+ // We remove the opening tag.
+ parent.remove_child(&first_node).unwrap();
+
+ let mut current_node;
+ let mut nested_layers = 1;
+
+ loop {
+ current_node = collect_from
+ .pop_front()
+ .unwrap_or_else(|| panic!("expected {} closing tag, found EOF", kind_name));
+
+ if current_node.node_type() == Node::COMMENT_NODE {
+ if is_open_tag(¤t_node) {
+ // We found another opening tag, we need to increase component counter.
+ nested_layers += 1;
+ } else if is_close_tag(¤t_node) {
+ // We found a closing tag, minus component counter.
+ nested_layers -= 1;
+ if nested_layers == 0 {
+ // We have found the end of the current tag we are collecting, breaking
+ // the loop.
+
+ // We remove the closing tag.
+ parent.remove_child(¤t_node).unwrap();
+ break;
+ }
+ }
+ }
+
+ nodes.push_back(current_node.clone());
+ }
+
+ Self(nodes)
+ }
+
+ /// Remove child nodes until first non-text node.
+ pub(crate) fn trim_start_text_nodes(&mut self, parent: &Element) {
+ while let Some(ref m) = self.front().cloned() {
+ if m.node_type() == Node::TEXT_NODE {
+ self.pop_front();
+
+ parent.remove_child(m).unwrap();
+ } else {
+ break;
+ }
+ }
+ }
+
+ /// Deeply clones all nodes.
+ pub(crate) fn deep_clone(&self) -> Self {
+ let nodes = self
+ .iter()
+ .map(|m| m.clone_node_with_deep(true).expect("failed to clone node."))
+ .collect::>();
+
+ Self(nodes)
+ }
+ }
+}
+
+#[cfg(feature = "hydration")]
+pub(crate) use feat_hydration::*;
+
pub(crate) fn insert_node(node: &Node, parent: &Element, next_sibling: Option<&Node>) {
match next_sibling {
Some(next_sibling) => parent
diff --git a/packages/yew/src/virtual_dom/vcomp.rs b/packages/yew/src/virtual_dom/vcomp.rs
index 0587a0ab9b6..7a959d4d296 100644
--- a/packages/yew/src/virtual_dom/vcomp.rs
+++ b/packages/yew/src/virtual_dom/vcomp.rs
@@ -1,5 +1,7 @@
//! This module contains the implementation of a virtual component (`VComp`).
+#[cfg(feature = "hydration")]
+use super::Fragment;
use super::{Key, VDiff, VNode};
use crate::html::{AnyScope, BaseComponent, NodeRef, Scope, Scoped};
#[cfg(feature = "ssr")]
@@ -9,14 +11,14 @@ use std::borrow::Borrow;
use std::fmt;
use std::ops::Deref;
use std::rc::Rc;
-#[cfg(debug_assertions)]
use std::sync::atomic::{AtomicUsize, Ordering};
use web_sys::Element;
-#[cfg(debug_assertions)]
thread_local! {
+ #[cfg(debug_assertions)]
static EVENT_HISTORY: std::cell::RefCell>>
= Default::default();
+
static COMP_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
}
@@ -50,7 +52,6 @@ pub struct VComp {
pub(crate) node_ref: NodeRef,
pub(crate) key: Option,
- #[cfg(debug_assertions)]
pub(crate) id: usize,
}
@@ -70,7 +71,6 @@ impl Clone for VComp {
node_ref: self.node_ref.clone(),
key: self.key.clone(),
- #[cfg(debug_assertions)]
id: self.id,
}
}
@@ -133,6 +133,8 @@ impl VComp {
where
COMP: BaseComponent,
{
+ let id = Self::next_id();
+
VComp {
type_id: TypeId::of::(),
node_ref,
@@ -140,8 +142,7 @@ impl VComp {
scope: None,
key,
- #[cfg(debug_assertions)]
- id: Self::next_id(),
+ id,
}
}
@@ -164,7 +165,6 @@ impl VComp {
})
}
- #[cfg(debug_assertions)]
pub(crate) fn next_id() -> usize {
COMP_ID_COUNTER.with(|m| m.fetch_add(1, Ordering::Relaxed))
}
@@ -186,7 +186,17 @@ trait Mountable {
&'a self,
w: &'a mut String,
parent_scope: &'a AnyScope,
+ hydratable: bool,
) -> LocalBoxFuture<'a, ()>;
+
+ #[cfg(feature = "hydration")]
+ fn hydrate(
+ self: Box,
+ parent_scope: &AnyScope,
+ parent: Element,
+ fragment: &mut Fragment,
+ node_ref: NodeRef,
+ ) -> Box;
}
struct PropsWrapper {
@@ -230,13 +240,30 @@ impl Mountable for PropsWrapper {
&'a self,
w: &'a mut String,
parent_scope: &'a AnyScope,
+ hydratable: bool,
) -> LocalBoxFuture<'a, ()> {
async move {
let scope: Scope = Scope::new(Some(parent_scope.clone()));
- scope.render_to_string(w, self.props.clone()).await;
+ scope
+ .render_to_string(w, self.props.clone(), hydratable)
+ .await;
}
.boxed_local()
}
+
+ #[cfg(feature = "hydration")]
+ fn hydrate(
+ self: Box,
+ parent_scope: &AnyScope,
+ parent: Element,
+ fragment: &mut Fragment,
+ node_ref: NodeRef,
+ ) -> Box {
+ let scope: Scope = Scope::new(Some(parent_scope.clone()));
+ scope.hydrate_in_place(parent, fragment, node_ref, self.props);
+
+ Box::new(scope)
+ }
}
impl VDiff for VComp {
@@ -310,18 +337,56 @@ mod feat_ssr {
use super::*;
impl VComp {
- pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) {
+ pub(crate) async fn render_to_string(
+ &self,
+ w: &mut String,
+ parent_scope: &AnyScope,
+ hydratable: bool,
+ ) {
self.mountable
.as_ref()
.map(|m| m.copy())
.unwrap()
- .render_to_string(w, parent_scope)
+ .render_to_string(w, parent_scope, hydratable)
.await;
}
}
}
-#[cfg(test)]
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use crate::virtual_dom::{Fragment, VHydrate};
+
+ impl VHydrate for VComp {
+ fn hydrate(
+ &mut self,
+ parent_scope: &AnyScope,
+ parent: &Element,
+ fragment: &mut Fragment,
+ ) -> NodeRef {
+ let mountable = self
+ .mountable
+ .take()
+ .expect("tried to hydrate a mounted VComp");
+
+ let scoped = mountable.hydrate(
+ parent_scope,
+ parent.clone(),
+ fragment,
+ self.node_ref.clone(),
+ );
+
+ self.scope = Some(scoped);
+
+ self.node_ref.clone()
+ }
+ }
+}
+
+#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
use super::*;
use crate::scheduler;
@@ -603,7 +668,7 @@ mod tests {
}
}
-#[cfg(test)]
+#[cfg(all(test, target_arch = "wasm32"))]
mod layout_tests {
extern crate self as yew;
@@ -940,7 +1005,8 @@ mod ssr_tests {
}
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
diff --git a/packages/yew/src/virtual_dom/vlist.rs b/packages/yew/src/virtual_dom/vlist.rs
index e655cf873ac..8b46c6a51f7 100644
--- a/packages/yew/src/virtual_dom/vlist.rs
+++ b/packages/yew/src/virtual_dom/vlist.rs
@@ -289,12 +289,17 @@ mod feat_ssr {
use super::*;
impl VList {
- pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) {
+ pub(crate) async fn render_to_string(
+ &self,
+ w: &mut String,
+ parent_scope: &AnyScope,
+ hydratable: bool,
+ ) {
// Concurrently render all children.
for fragment in futures::future::join_all(self.children.iter().map(|m| async move {
let mut w = String::new();
- m.render_to_string(&mut w, parent_scope).await;
+ m.render_to_string(&mut w, parent_scope, hydratable).await;
w
}))
@@ -375,7 +380,36 @@ impl VDiff for VList {
}
}
-#[cfg(test)]
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use crate::virtual_dom::{Fragment, VHydrate};
+
+ impl VHydrate for VList {
+ fn hydrate(
+ &mut self,
+ parent_scope: &AnyScope,
+ parent: &Element,
+ fragment: &mut Fragment,
+ ) -> NodeRef {
+ let node_ref = NodeRef::default();
+
+ for (index, child) in self.children.iter_mut().enumerate() {
+ let child_node_ref = child.hydrate(parent_scope, parent, fragment);
+
+ if index == 0 {
+ node_ref.reuse(child_node_ref);
+ }
+ }
+
+ node_ref
+ }
+ }
+}
+
+#[cfg(all(test, target_arch = "wasm32"))]
mod layout_tests {
extern crate self as yew;
@@ -453,7 +487,7 @@ mod layout_tests {
}
}
-#[cfg(test)]
+#[cfg(all(test, target_arch = "wasm32"))]
mod layout_tests_keys {
extern crate self as yew;
@@ -1306,7 +1340,8 @@ mod ssr_tests {
html! { {"Hello "}{s}{"!"}
}
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
@@ -1336,7 +1371,8 @@ mod ssr_tests {
}
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
diff --git a/packages/yew/src/virtual_dom/vnode.rs b/packages/yew/src/virtual_dom/vnode.rs
index dde3d045223..cd9091a39b4 100644
--- a/packages/yew/src/virtual_dom/vnode.rs
+++ b/packages/yew/src/virtual_dom/vnode.rs
@@ -30,6 +30,8 @@ pub enum VNode {
}
impl VNode {
+ pub const EMPTY: &'static VNode = &VNode::VList(VList::new());
+
pub fn key(&self) -> Option {
match self {
VNode::VComp(vcomp) => vcomp.key.clone(),
@@ -38,7 +40,8 @@ impl VNode {
VNode::VTag(vtag) => vtag.key.clone(),
VNode::VText(_) => None,
VNode::VPortal(vportal) => vportal.node.key(),
- VNode::VSuspense(vsuspense) => vsuspense.key.clone(),
+ // VSuspenses are created by and is keyed by its VComp.
+ VNode::VSuspense(_) => None,
}
}
@@ -50,7 +53,7 @@ impl VNode {
VNode::VRef(_) | VNode::VText(_) => false,
VNode::VTag(vtag) => vtag.key.is_some(),
VNode::VPortal(vportal) => vportal.node.has_key(),
- VNode::VSuspense(vsuspense) => vsuspense.key.is_some(),
+ VNode::VSuspense(_) => false,
}
}
@@ -292,6 +295,15 @@ impl PartialEq for VNode {
(VNode::VRef(a), VNode::VRef(b)) => a == b,
// TODO: Need to improve PartialEq for VComp before enabling.
(VNode::VComp(_), VNode::VComp(_)) => false,
+ (VNode::VList(a), b) => {
+ if let Some(m) = a.iter().next() {
+ if a.len() == 1 {
+ return m == b;
+ }
+ }
+
+ false
+ }
_ => false,
}
}
@@ -309,13 +321,20 @@ mod feat_ssr {
&'a self,
w: &'a mut String,
parent_scope: &'a AnyScope,
+ hydratable: bool,
) -> LocalBoxFuture<'a, ()> {
async move {
match self {
- VNode::VTag(vtag) => vtag.render_to_string(w, parent_scope).await,
- VNode::VText(vtext) => vtext.render_to_string(w).await,
- VNode::VComp(vcomp) => vcomp.render_to_string(w, parent_scope).await,
- VNode::VList(vlist) => vlist.render_to_string(w, parent_scope).await,
+ VNode::VTag(vtag) => vtag.render_to_string(w, parent_scope, hydratable).await,
+ VNode::VText(vtext) => {
+ vtext.render_to_string(w, parent_scope, hydratable).await
+ }
+ VNode::VComp(vcomp) => {
+ vcomp.render_to_string(w, parent_scope, hydratable).await
+ }
+ VNode::VList(vlist) => {
+ vlist.render_to_string(w, parent_scope, hydratable).await
+ }
// We are pretty safe here as it's not possible to get a web_sys::Node without DOM
// support in the first place.
//
@@ -327,7 +346,9 @@ mod feat_ssr {
// Portals are not rendered.
VNode::VPortal(_) => {}
VNode::VSuspense(vsuspense) => {
- vsuspense.render_to_string(w, parent_scope).await
+ vsuspense
+ .render_to_string(w, parent_scope, hydratable)
+ .await
}
}
}
@@ -336,7 +357,40 @@ mod feat_ssr {
}
}
-#[cfg(test)]
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use crate::virtual_dom::{Fragment, VHydrate};
+
+ impl VHydrate for VNode {
+ fn hydrate(
+ &mut self,
+ parent_scope: &AnyScope,
+ parent: &Element,
+ fragment: &mut Fragment,
+ ) -> NodeRef {
+ match self {
+ VNode::VTag(vtag) => vtag.hydrate(parent_scope, parent, fragment),
+ VNode::VText(vtext) => vtext.hydrate(parent_scope, parent, fragment),
+ VNode::VComp(vcomp) => vcomp.hydrate(parent_scope, parent, fragment),
+ VNode::VList(vlist) => vlist.hydrate(parent_scope, parent, fragment),
+ // You cannot hydrate a VRef.
+ VNode::VRef(_) => {
+ panic!("VRef is not hydratable. Try move it to a component mounted after an effect.")
+ }
+ // You cannot hydrate a VPortal.
+ VNode::VPortal(_) => {
+ panic!("VPortal is not hydratable. Try move it to a component mounted after an effect.")
+ }
+ VNode::VSuspense(vsuspense) => vsuspense.hydrate(parent_scope, parent, fragment),
+ }
+ }
+ }
+}
+
+#[cfg(all(test, target_arch = "wasm32"))]
mod layout_tests {
use super::*;
use crate::tests::layout_tests::{diff_layouts, TestLayout};
diff --git a/packages/yew/src/virtual_dom/vportal.rs b/packages/yew/src/virtual_dom/vportal.rs
index b1ef039d598..0a667996488 100644
--- a/packages/yew/src/virtual_dom/vportal.rs
+++ b/packages/yew/src/virtual_dom/vportal.rs
@@ -103,7 +103,7 @@ impl VPortal {
}
}
-#[cfg(test)]
+#[cfg(all(test, target_arch = "wasm32"))]
mod layout_tests {
extern crate self as yew;
diff --git a/packages/yew/src/virtual_dom/vsuspense.rs b/packages/yew/src/virtual_dom/vsuspense.rs
index 8d47e7a3053..02415c29c1b 100644
--- a/packages/yew/src/virtual_dom/vsuspense.rs
+++ b/packages/yew/src/virtual_dom/vsuspense.rs
@@ -1,7 +1,19 @@
-use super::{Key, VDiff, VNode};
+#[cfg(feature = "hydration")]
+use super::Fragment;
+use super::{VDiff, VNode};
use crate::html::{AnyScope, NodeRef};
use web_sys::{Element, Node};
+/// An enum to Respresent Fallback UI for a VSuspense.
+#[derive(Clone, Debug, PartialEq)]
+enum VSuspenseFallback {
+ /// Suspense Fallback during Rendering
+ Render { root_node: Box },
+ /// Suspense Fallback during Hydration
+ #[cfg(feature = "hydration")]
+ Hydration { fragment: Fragment },
+}
+
/// This struct represents a suspendable DOM fragment.
#[derive(Clone, Debug, PartialEq)]
pub struct VSuspense {
@@ -9,63 +21,84 @@ pub struct VSuspense {
children: Box,
/// Fallback nodes when suspended.
- fallback: Box,
+ ///
+ /// None if not suspended.
+ fallback: Option,
- /// The element to attach to when children is not attached to DOM
detached_parent: Option,
-
- /// Whether the current status is suspended.
- suspended: bool,
-
- /// The Key.
- pub(crate) key: Option,
}
impl VSuspense {
pub(crate) fn new(
children: VNode,
- fallback: VNode,
+ fallback: Option,
detached_parent: Option,
- suspended: bool,
- key: Option,
) -> Self {
Self {
children: children.into(),
- fallback: fallback.into(),
+ fallback: fallback.map(|m| VSuspenseFallback::Render {
+ root_node: m.into(),
+ }),
detached_parent,
- suspended,
- key,
}
}
pub(crate) fn first_node(&self) -> Option {
- if self.suspended {
- self.fallback.first_node()
- } else {
- self.children.first_node()
+ match self.fallback {
+ Some(VSuspenseFallback::Render { ref root_node, .. }) => root_node.first_node(),
+
+ #[cfg(feature = "hydration")]
+ Some(VSuspenseFallback::Hydration { ref fragment, .. }) => fragment.front().cloned(),
+
+ None => self.children.first_node(),
}
}
}
impl VDiff for VSuspense {
fn detach(&mut self, parent: &Element, parent_to_detach: bool) {
- if self.suspended {
- self.fallback.detach(parent, parent_to_detach);
- if let Some(ref m) = self.detached_parent {
- self.children.detach(m, false);
+ let detached_parent = self.detached_parent.as_ref().expect("no detached parent?");
+
+ match self.fallback {
+ Some(VSuspenseFallback::Render { ref mut root_node }) => {
+ root_node.detach(parent, parent_to_detach);
+ self.children.detach(detached_parent, true);
+ }
+
+ #[cfg(feature = "hydration")]
+ Some(VSuspenseFallback::Hydration { ref fragment }) => {
+ if !parent_to_detach {
+ for node in fragment.iter() {
+ parent
+ .remove_child(node)
+ .expect("failed to remove child element");
+ }
+ }
+
+ self.children.detach(detached_parent, true);
+ }
+
+ None => {
+ self.children.detach(parent, parent_to_detach);
}
- } else {
- self.children.detach(parent, parent_to_detach);
}
}
fn shift(&self, previous_parent: &Element, next_parent: &Element, next_sibling: NodeRef) {
- if self.suspended {
- self.fallback
- .shift(previous_parent, next_parent, next_sibling);
- } else {
- self.children
- .shift(previous_parent, next_parent, next_sibling);
+ match self.fallback {
+ Some(VSuspenseFallback::Render { ref root_node }) => {
+ root_node.shift(previous_parent, next_parent, next_sibling);
+ }
+
+ #[cfg(feature = "hydration")]
+ Some(VSuspenseFallback::Hydration { ref fragment }) => {
+ fragment.shift(previous_parent, next_parent, next_sibling)
+ }
+
+ None => {
+ self.children
+ .shift(previous_parent, next_parent, next_sibling);
+ }
}
}
@@ -78,74 +111,193 @@ impl VDiff for VSuspense {
) -> NodeRef {
let detached_parent = self.detached_parent.as_ref().expect("no detached parent?");
- let (already_suspended, children_ancestor, fallback_ancestor) = match ancestor {
+ let (children_ancestor, fallback_ancestor) = match ancestor {
Some(VNode::VSuspense(mut m)) => {
// We only preserve the child state if they are the same suspense.
- if m.key != self.key || self.detached_parent != m.detached_parent {
+ if self.detached_parent != m.detached_parent {
m.detach(parent, false);
- (false, None, None)
+ (None, None)
} else {
- (m.suspended, Some(*m.children), Some(*m.fallback))
+ (Some(*m.children), m.fallback)
}
}
Some(mut m) => {
m.detach(parent, false);
- (false, None, None)
+ (None, None)
}
- None => (false, None, None),
+ None => (None, None),
};
// When it's suspended, we render children into an element that is detached from the dom
// tree while rendering fallback UI into the original place where children resides in.
- match (self.suspended, already_suspended) {
- (true, true) => {
- self.children.apply(
- parent_scope,
- detached_parent,
- NodeRef::default(),
- children_ancestor,
- );
-
- self.fallback
- .apply(parent_scope, parent, next_sibling, fallback_ancestor)
+ match (self.fallback.as_mut(), fallback_ancestor) {
+ // Currently suspended, continue to be suspended.
+ (Some(fallback), Some(fallback_ancestor)) => {
+ match (fallback, fallback_ancestor) {
+ (
+ VSuspenseFallback::Render {
+ root_node: ref mut fallback,
+ },
+ VSuspenseFallback::Render {
+ root_node: fallback_ancestor,
+ },
+ ) => {
+ self.children.apply(
+ parent_scope,
+ detached_parent,
+ NodeRef::default(),
+ children_ancestor,
+ );
+ fallback.apply(parent_scope, parent, next_sibling, Some(*fallback_ancestor))
+ }
+
+ // current fallback cannot be Hydration.
+ #[cfg(feature = "hydration")]
+ (VSuspenseFallback::Hydration { .. }, VSuspenseFallback::Render { .. }) => {
+ panic!("invalid suspense state!")
+ }
+
+ #[cfg(feature = "hydration")]
+ (_, VSuspenseFallback::Hydration { fragment }) => {
+ self.children.apply(
+ parent_scope,
+ detached_parent,
+ NodeRef::default(),
+ children_ancestor,
+ );
+
+ let node_ref = NodeRef::default();
+ node_ref.set(fragment.front().cloned());
+
+ self.fallback = Some(VSuspenseFallback::Hydration { fragment });
+
+ node_ref
+ }
+ }
}
- (false, false) => {
+ // Currently not suspended, continue to be not suspended.
+ (None, None) => {
self.children
.apply(parent_scope, parent, next_sibling, children_ancestor)
}
- (true, false) => {
- children_ancestor.as_ref().unwrap().shift(
- parent,
- detached_parent,
- NodeRef::default(),
- );
-
- self.children.apply(
- parent_scope,
- detached_parent,
- NodeRef::default(),
- children_ancestor,
- );
-
- // first render of fallback, ancestor needs to be None.
- self.fallback
- .apply(parent_scope, parent, next_sibling, None)
+ // The children is about to be suspended.
+ (Some(fallback), None) => {
+ match fallback {
+ VSuspenseFallback::Render {
+ root_node: ref mut fallback,
+ } => {
+ if let Some(ref m) = children_ancestor {
+ m.shift(parent, detached_parent, NodeRef::default());
+ }
+
+ self.children.apply(
+ parent_scope,
+ detached_parent,
+ NodeRef::default(),
+ children_ancestor,
+ );
+
+ // first render of fallback, ancestor needs to be None.
+ fallback.apply(parent_scope, parent, next_sibling, None)
+ }
+
+ // current fallback cannot be Hydration.
+ #[cfg(feature = "hydration")]
+ VSuspenseFallback::Hydration { .. } => {
+ panic!("invalid suspense state!")
+ }
+ }
}
- (false, true) => {
- fallback_ancestor.unwrap().detach(parent, false);
+ // The children is about to be resumed.
+ (None, Some(fallback_ancestor)) => {
+ match fallback_ancestor {
+ VSuspenseFallback::Render {
+ root_node: mut fallback_ancestor,
+ } => {
+ fallback_ancestor.detach(parent, false);
+
+ if let Some(ref m) = children_ancestor {
+ m.shift(detached_parent, parent, next_sibling.clone());
+ }
+
+ self.children
+ .apply(parent_scope, parent, next_sibling, children_ancestor)
+ }
+
+ #[cfg(feature = "hydration")]
+ VSuspenseFallback::Hydration { fragment } => {
+ // We can simply remove the fallback fragments it's not connected to
+ // anything.
+ for node in fragment.iter() {
+ parent
+ .remove_child(node)
+ .expect("failed to remove fragment node.");
+ }
+
+ if let Some(ref m) = children_ancestor {
+ m.shift(detached_parent, parent, next_sibling.clone());
+ }
+
+ self.children
+ .apply(parent_scope, parent, next_sibling, children_ancestor)
+ }
+ }
+ }
+ }
+ }
+}
- children_ancestor.as_ref().unwrap().shift(
- detached_parent,
- parent,
- next_sibling.clone(),
- );
- self.children
- .apply(parent_scope, parent, next_sibling, children_ancestor)
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use crate::virtual_dom::{Fragment, VHydrate};
+
+ impl VHydrate for VSuspense {
+ fn hydrate(
+ &mut self,
+ parent_scope: &AnyScope,
+ parent: &Element,
+ fragment: &mut Fragment,
+ ) -> NodeRef {
+ let detached_parent = self.detached_parent.as_ref().expect("no detached parent?");
+
+ // We start hydration with the VSuspense being suspended.
+ // A subsequent render will resume the VSuspense if not needed to be suspended.
+
+ let fallback_nodes =
+ Fragment::collect_between(fragment, parent, "", "?", ">", "suspense");
+
+ let mut nodes = fallback_nodes.deep_clone();
+
+ for node in nodes.iter() {
+ detached_parent.append_child(node).unwrap();
}
+
+ self.children
+ .hydrate(parent_scope, detached_parent, &mut nodes);
+
+ // We trim all leading text nodes before checking as it's likely these are whitespaces.
+ nodes.trim_start_text_nodes(detached_parent);
+
+ assert!(nodes.is_empty(), "expected end of suspense, found node.");
+
+ let first_node = fallback_nodes
+ .front()
+ .cloned()
+ .map(NodeRef::new)
+ .unwrap_or_else(NodeRef::default);
+
+ self.fallback = Some(VSuspenseFallback::Hydration {
+ fragment: fallback_nodes,
+ });
+
+ first_node
}
}
}
@@ -155,9 +307,23 @@ mod feat_ssr {
use super::*;
impl VSuspense {
- pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) {
- // always render children on the server side.
- self.children.render_to_string(w, parent_scope).await;
+ pub(crate) async fn render_to_string(
+ &self,
+ w: &mut String,
+ parent_scope: &AnyScope,
+ hydratable: bool,
+ ) {
+ if hydratable {
+ w.push_str("");
+ }
+ // always render children on the server side (for now).
+ self.children
+ .render_to_string(w, parent_scope, hydratable)
+ .await;
+
+ if hydratable {
+ w.push_str("");
+ }
}
}
}
@@ -245,7 +411,8 @@ mod ssr_tests {
let s = local
.run_until(async move {
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
renderer.render().await
})
diff --git a/packages/yew/src/virtual_dom/vtag.rs b/packages/yew/src/virtual_dom/vtag.rs
index 0df3eafd966..55bef38cc9e 100644
--- a/packages/yew/src/virtual_dom/vtag.rs
+++ b/packages/yew/src/virtual_dom/vtag.rs
@@ -653,8 +653,19 @@ mod feat_ssr {
use crate::virtual_dom::VText;
use std::fmt::Write;
+ // Elements that cannot have any child elements.
+ static VOID_ELEMENTS: &[&str; 14] = &[
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
+ "source", "track", "wbr",
+ ];
+
impl VTag {
- pub(crate) async fn render_to_string(&self, w: &mut String, parent_scope: &AnyScope) {
+ pub(crate) async fn render_to_string(
+ &self,
+ w: &mut String,
+ parent_scope: &AnyScope,
+ hydratable: bool,
+ ) {
write!(w, "<{}", self.tag()).unwrap();
let write_attr = |w: &mut String, name: &str, val: Option<&str>| {
@@ -685,7 +696,9 @@ mod feat_ssr {
VTagInner::Input(_) => {}
VTagInner::Textarea { .. } => {
if let Some(m) = self.value() {
- VText::new(m.to_owned()).render_to_string(w).await;
+ VText::new(m.to_owned())
+ .render_to_string(w, parent_scope, hydratable)
+ .await;
}
w.push_str("");
@@ -695,16 +708,89 @@ mod feat_ssr {
ref children,
..
} => {
- children.render_to_string(w, parent_scope).await;
+ if !VOID_ELEMENTS.contains(&tag.as_ref()) {
+ // We don't write children of void elements nor closing tags.
+ children.render_to_string(w, parent_scope, hydratable).await;
- write!(w, "{}>", tag).unwrap();
+ write!(w, "{}>", tag).unwrap();
+ }
}
}
}
}
}
-#[cfg(test)]
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use web_sys::Node;
+
+ use crate::virtual_dom::{Fragment, VHydrate};
+
+ impl VHydrate for VTag {
+ fn hydrate(
+ &mut self,
+ parent_scope: &AnyScope,
+ parent: &Element,
+ fragment: &mut Fragment,
+ ) -> NodeRef {
+ // We trim all text nodes as it's likely these are whitespaces.
+ fragment.trim_start_text_nodes(parent);
+
+ let node = fragment
+ .pop_front()
+ .unwrap_or_else(|| panic!("expected element of type {}, found EOF.", self.tag()));
+
+ assert_eq!(
+ node.node_type(),
+ Node::ELEMENT_NODE,
+ "expected element, found node type {}.",
+ node.node_type(),
+ );
+ let el = node.dyn_into::().expect("expected an element.");
+
+ assert_eq!(
+ el.tag_name().to_lowercase(),
+ self.tag(),
+ "expected element of kind {}, found {}.",
+ self.tag(),
+ el.tag_name().to_lowercase(),
+ );
+
+ // We simply registers listeners and updates all attributes.
+ self.attributes.apply(&el);
+ self.listeners.apply(&el);
+
+ // For input and textarea elements, we update their value anyways.
+ match &mut self.inner {
+ VTagInner::Input(f) => {
+ f.apply(el.unchecked_ref());
+ }
+ VTagInner::Textarea { value } => {
+ value.apply(el.unchecked_ref());
+ }
+ VTagInner::Other { children, .. } => {
+ let mut nodes = Fragment::collect_children(&el);
+ if !children.is_empty() {
+ children.hydrate(parent_scope, &el, &mut nodes);
+ }
+
+ nodes.trim_start_text_nodes(parent);
+
+ assert!(nodes.is_empty(), "expected EOF, found node.");
+ }
+ }
+
+ self.node_ref.set(Some(el.deref().clone()));
+ self.reference = el.into();
+ self.node_ref.clone()
+ }
+ }
+}
+
+#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
use super::*;
use crate::{html, Html};
@@ -1262,7 +1348,7 @@ mod tests {
}
}
-#[cfg(test)]
+#[cfg(all(test, target_arch = "wasm32"))]
mod layout_tests {
extern crate self as yew;
@@ -1521,7 +1607,8 @@ mod ssr_tests {
html! {
}
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
@@ -1535,7 +1622,8 @@ mod ssr_tests {
html! {
}
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
@@ -1549,7 +1637,8 @@ mod ssr_tests {
html! { {"Hello!"}
}
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
@@ -1563,7 +1652,8 @@ mod ssr_tests {
html! { {"Hello!"}
}
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
@@ -1577,7 +1667,8 @@ mod ssr_tests {
html! { }
}
- let renderer = ServerRenderer::::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
let s = renderer.render().await;
diff --git a/packages/yew/src/virtual_dom/vtext.rs b/packages/yew/src/virtual_dom/vtext.rs
index 8345a5cfda9..42f1e604b00 100644
--- a/packages/yew/src/virtual_dom/vtext.rs
+++ b/packages/yew/src/virtual_dom/vtext.rs
@@ -33,7 +33,12 @@ mod feat_ssr {
use super::*;
impl VText {
- pub(crate) async fn render_to_string(&self, w: &mut String) {
+ pub(crate) async fn render_to_string(
+ &self,
+ w: &mut String,
+ _parent_scope: &AnyScope,
+ _hydratable: bool,
+ ) {
html_escape::encode_text_to_string(&self.text, w);
}
}
@@ -119,6 +124,62 @@ impl PartialEq for VText {
}
}
+#[cfg_attr(documenting, doc(cfg(feature = "hydration")))]
+#[cfg(feature = "hydration")]
+mod feat_hydration {
+ use super::*;
+
+ use web_sys::Node;
+
+ use crate::virtual_dom::{Fragment, VHydrate};
+ use wasm_bindgen::JsCast;
+
+ use crate::virtual_dom::insert_node;
+
+ impl VHydrate for VText {
+ fn hydrate(
+ &mut self,
+ _parent_scope: &AnyScope,
+ parent: &Element,
+ fragment: &mut Fragment,
+ ) -> NodeRef {
+ assert!(
+ self.reference.is_none(),
+ "trying to hydrate a mounted VText."
+ );
+
+ if let Some(m) = fragment.front().cloned() {
+ // better safe than sorry.
+ if m.node_type() == Node::TEXT_NODE {
+ if let Ok(m) = m.dyn_into::() {
+ // pop current node.
+ fragment.pop_front();
+
+ // TODO: It may make sense to assert the text content in the text node against
+ // the VText when #[cfg(debug_assertions)] is true, but this may be complicated.
+ // We always replace the text value for now.
+ //
+ // Please see the next comment for a detailed explanation.
+ m.set_node_value(Some(self.text.as_ref()));
+ self.reference = Some(m.clone());
+
+ return NodeRef::new(m.into());
+ }
+ }
+ }
+
+ // If there are multiple text nodes placed back-to-back in SSR, it may be parsed as a single
+ // text node by browser, hence we need to add extra text nodes here if the next node is not a text node.
+ // Similarly, the value of the text node may be a combination of multiple VText vnodes.
+ // So we always need to override their values.
+ let text_node = document().create_text_node(&self.text);
+ insert_node(&text_node, parent, fragment.front());
+ self.reference = Some(text_node.clone());
+ NodeRef::new(text_node.into())
+ }
+ }
+}
+
#[cfg(test)]
mod test {
extern crate self as yew;
@@ -143,7 +204,7 @@ mod test {
}
}
-#[cfg(test)]
+#[cfg(all(test, target_arch = "wasm32"))]
mod layout_tests {
extern crate self as yew;
@@ -200,16 +261,21 @@ mod layout_tests {
mod ssr_tests {
use tokio::test;
- use super::*;
+ use crate::prelude::*;
+ use crate::ServerRenderer;
#[test]
async fn test_simple_str() {
- let vtext = VText::new("abc");
+ #[function_component]
+ fn Comp() -> Html {
+ html! { "abc" }
+ }
- let mut s = String::new();
+ let mut renderer = ServerRenderer::::new();
+ renderer.set_hydratable(false);
- vtext.render_to_string(&mut s).await;
+ let s = renderer.render().await;
- assert_eq!("abc", s.as_str());
+ assert_eq!(s, r#"abc"#);
}
}
diff --git a/packages/yew/tests/hydration.rs b/packages/yew/tests/hydration.rs
new file mode 100644
index 00000000000..0dca41a7106
--- /dev/null
+++ b/packages/yew/tests/hydration.rs
@@ -0,0 +1,542 @@
+#![cfg(feature = "hydration")]
+
+use std::rc::Rc;
+use std::time::Duration;
+
+mod common;
+
+use common::{obtain_result, obtain_result_by_id};
+
+use gloo::timers::future::sleep;
+use wasm_bindgen::JsCast;
+use wasm_bindgen_futures::spawn_local;
+use wasm_bindgen_test::*;
+use web_sys::{HtmlElement, HtmlTextAreaElement};
+use yew::prelude::*;
+use yew::suspense::{Suspension, SuspensionResult};
+use yew::{Renderer, ServerRenderer};
+
+wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
+
+#[wasm_bindgen_test]
+async fn hydration_works() {
+ #[function_component]
+ fn Comp() -> Html {
+ let ctr = use_state_eq(|| 0);
+
+ let onclick = {
+ let ctr = ctr.clone();
+
+ Callback::from(move |_| {
+ ctr.set(*ctr + 1);
+ })
+ };
+
+ html! {
+
+ {"Counter: "}{*ctr}
+ {"+1"}
+
+ }
+ }
+
+ #[function_component]
+ fn App() -> Html {
+ html! {
+
+
+
+ }
+ }
+
+ let s = ServerRenderer::::new().render().await;
+
+ gloo::utils::document()
+ .query_selector("#output")
+ .unwrap()
+ .unwrap()
+ .set_inner_html(&s);
+
+ sleep(Duration::ZERO).await;
+
+ Renderer::::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
+ .hydrate();
+
+ sleep(Duration::ZERO).await;
+
+ let result = obtain_result_by_id("output");
+
+ // no placeholders, hydration is successful.
+ assert_eq!(
+ result,
+ r#""#
+ );
+
+ gloo_utils::document()
+ .query_selector(".increase")
+ .unwrap()
+ .unwrap()
+ .dyn_into::()
+ .unwrap()
+ .click();
+
+ sleep(Duration::ZERO).await;
+
+ let result = obtain_result_by_id("output");
+
+ assert_eq!(
+ result,
+ r#""#
+ );
+}
+
+#[wasm_bindgen_test]
+async fn hydration_with_suspense() {
+ #[derive(PartialEq)]
+ pub struct SleepState {
+ s: Suspension,
+ }
+
+ impl SleepState {
+ fn new() -> Self {
+ let (s, handle) = Suspension::new();
+
+ spawn_local(async move {
+ sleep(Duration::from_millis(50)).await;
+
+ handle.resume();
+ });
+
+ Self { s }
+ }
+ }
+
+ impl Reducible for SleepState {
+ type Action = ();
+
+ fn reduce(self: Rc, _action: Self::Action) -> Rc {
+ Self::new().into()
+ }
+ }
+
+ #[hook]
+ pub fn use_sleep() -> SuspensionResult> {
+ let sleep_state = use_reducer(SleepState::new);
+
+ if sleep_state.s.resumed() {
+ Ok(Rc::new(move || sleep_state.dispatch(())))
+ } else {
+ Err(sleep_state.s.clone())
+ }
+ }
+
+ #[function_component(Content)]
+ fn content() -> HtmlResult {
+ let resleep = use_sleep()?;
+
+ let value = use_state(|| 0);
+
+ let on_increment = {
+ let value = value.clone();
+
+ Callback::from(move |_: MouseEvent| {
+ value.set(*value + 1);
+ })
+ };
+
+ let on_take_a_break = Callback::from(move |_: MouseEvent| (resleep.clone())());
+
+ Ok(html! {
+
+
{*value}
+
{"increase"}
+
+ {"Take a break!"}
+
+
+ })
+ }
+
+ #[function_component(App)]
+ fn app() -> Html {
+ let fallback = html! {{"wait..."}
};
+
+ html! {
+
+
+
+
+
+ }
+ }
+
+ let s = ServerRenderer::::new().render().await;
+
+ gloo::utils::document()
+ .query_selector("#output")
+ .unwrap()
+ .unwrap()
+ .set_inner_html(&s);
+
+ sleep(Duration::ZERO).await;
+
+ Renderer::::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
+ .hydrate();
+
+ sleep(Duration::from_millis(10)).await;
+
+ let result = obtain_result();
+
+ // still hydrating, during hydration, the server rendered result is shown.
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+
+ sleep(Duration::from_millis(50)).await;
+
+ let result = obtain_result();
+
+ // hydrated.
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+
+ gloo_utils::document()
+ .query_selector(".increase")
+ .unwrap()
+ .unwrap()
+ .dyn_into::()
+ .unwrap()
+ .click();
+
+ sleep(Duration::from_millis(50)).await;
+
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+
+ gloo_utils::document()
+ .query_selector(".take-a-break")
+ .unwrap()
+ .unwrap()
+ .dyn_into::()
+ .unwrap()
+ .click();
+
+ sleep(Duration::from_millis(10)).await;
+ let result = obtain_result();
+ assert_eq!(result.as_str(), "wait...
");
+
+ sleep(Duration::from_millis(50)).await;
+
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+}
+
+#[wasm_bindgen_test]
+async fn hydration_with_suspense_not_suspended_at_start() {
+ #[derive(PartialEq)]
+ pub struct SleepState {
+ s: Option,
+ }
+
+ impl SleepState {
+ fn new() -> Self {
+ Self { s: None }
+ }
+ }
+
+ impl Reducible for SleepState {
+ type Action = ();
+
+ fn reduce(self: Rc, _action: Self::Action) -> Rc {
+ let (s, handle) = Suspension::new();
+
+ spawn_local(async move {
+ sleep(Duration::from_millis(50)).await;
+
+ handle.resume();
+ });
+
+ Self { s: Some(s) }.into()
+ }
+ }
+
+ #[hook]
+ pub fn use_sleep() -> SuspensionResult> {
+ let sleep_state = use_reducer(SleepState::new);
+
+ let s = match sleep_state.s.clone() {
+ Some(m) => m,
+ None => return Ok(Rc::new(move || sleep_state.dispatch(()))),
+ };
+
+ if s.resumed() {
+ Ok(Rc::new(move || sleep_state.dispatch(())))
+ } else {
+ Err(s)
+ }
+ }
+
+ #[function_component(Content)]
+ fn content() -> HtmlResult {
+ let resleep = use_sleep()?;
+
+ let value = use_state(|| "I am writing a long story...".to_string());
+
+ let on_text_input = {
+ let value = value.clone();
+
+ Callback::from(move |e: InputEvent| {
+ let input: HtmlTextAreaElement = e.target_unchecked_into();
+
+ value.set(input.value());
+ })
+ };
+
+ let on_take_a_break = Callback::from(move |_| (resleep.clone())());
+
+ Ok(html! {
+
+
+
+ {"Take a break!"}
+
+
+ })
+ }
+
+ #[function_component(App)]
+ fn app() -> Html {
+ let fallback = html! {{"wait..."}
};
+
+ html! {
+
+
+
+
+
+ }
+ }
+
+ let s = ServerRenderer::::new().render().await;
+
+ gloo::utils::document()
+ .query_selector("#output")
+ .unwrap()
+ .unwrap()
+ .set_inner_html(&s);
+
+ sleep(Duration::ZERO).await;
+
+ Renderer::::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
+ .hydrate();
+
+ sleep(Duration::from_millis(10)).await;
+
+ let result = obtain_result();
+
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+ gloo_utils::document()
+ .query_selector(".take-a-break")
+ .unwrap()
+ .unwrap()
+ .dyn_into::()
+ .unwrap()
+ .click();
+
+ sleep(Duration::from_millis(10)).await;
+
+ let result = obtain_result();
+ assert_eq!(result.as_str(), "wait...
");
+
+ sleep(Duration::from_millis(50)).await;
+
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+}
+
+#[wasm_bindgen_test]
+async fn hydration_nested_suspense_works() {
+ #[derive(PartialEq)]
+ pub struct SleepState {
+ s: Suspension,
+ }
+
+ impl SleepState {
+ fn new() -> Self {
+ let (s, handle) = Suspension::new();
+
+ spawn_local(async move {
+ sleep(Duration::from_millis(50)).await;
+
+ handle.resume();
+ });
+
+ Self { s }
+ }
+ }
+
+ impl Reducible for SleepState {
+ type Action = ();
+
+ fn reduce(self: Rc, _action: Self::Action) -> Rc {
+ Self::new().into()
+ }
+ }
+
+ #[hook]
+ pub fn use_sleep() -> SuspensionResult> {
+ let sleep_state = use_reducer(SleepState::new);
+
+ if sleep_state.s.resumed() {
+ Ok(Rc::new(move || sleep_state.dispatch(())))
+ } else {
+ Err(sleep_state.s.clone())
+ }
+ }
+
+ #[function_component(InnerContent)]
+ fn inner_content() -> HtmlResult {
+ let resleep = use_sleep()?;
+
+ let on_take_a_break = Callback::from(move |_: MouseEvent| (resleep.clone())());
+
+ Ok(html! {
+
+
+ {"Take a break!"}
+
+
+ })
+ }
+
+ #[function_component(Content)]
+ fn content() -> HtmlResult {
+ let resleep = use_sleep()?;
+
+ let fallback = html! {{"wait...(inner)"}
};
+
+ let on_take_a_break = Callback::from(move |_: MouseEvent| (resleep.clone())());
+
+ Ok(html! {
+
+
+ {"Take a break!"}
+
+
+
+
+
+ })
+ }
+
+ #[function_component(App)]
+ fn app() -> Html {
+ let fallback = html! {{"wait...(outer)"}
};
+
+ html! {
+
+
+
+
+
+ }
+ }
+
+ let s = ServerRenderer::::new().render().await;
+
+ gloo::utils::document()
+ .query_selector("#output")
+ .unwrap()
+ .unwrap()
+ .set_inner_html(&s);
+
+ sleep(Duration::ZERO).await;
+
+ Renderer::::with_root(gloo_utils::document().get_element_by_id("output").unwrap())
+ .hydrate();
+
+ // outer suspense is hydrating...
+ sleep(Duration::from_millis(10)).await;
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+
+ sleep(Duration::from_millis(50)).await;
+
+ // inner suspense is hydrating...
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+
+ sleep(Duration::from_millis(50)).await;
+
+ // hydrated.
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+
+ gloo_utils::document()
+ .query_selector(".take-a-break")
+ .unwrap()
+ .unwrap()
+ .dyn_into::()
+ .unwrap()
+ .click();
+
+ sleep(Duration::from_millis(10)).await;
+
+ let result = obtain_result();
+ assert_eq!(result.as_str(), "wait...(outer)
");
+
+ sleep(Duration::from_millis(50)).await;
+
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+
+ gloo_utils::document()
+ .query_selector(".take-a-break2")
+ .unwrap()
+ .unwrap()
+ .dyn_into::()
+ .unwrap()
+ .click();
+
+ sleep(Duration::from_millis(10)).await;
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#"Take a break!
wait...(inner)
"#
+ );
+
+ sleep(Duration::from_millis(50)).await;
+
+ let result = obtain_result();
+ assert_eq!(
+ result.as_str(),
+ r#""#
+ );
+}
diff --git a/tools/benchmark-hooks/Cargo.toml b/tools/benchmark-hooks/Cargo.toml
index 3c7d0c44061..78ae65f2ef6 100644
--- a/tools/benchmark-hooks/Cargo.toml
+++ b/tools/benchmark-hooks/Cargo.toml
@@ -14,10 +14,5 @@ wasm-bindgen = "=0.2.78"
web-sys = { version = "0.3.55", features = ["Window"]}
yew = "0.19.3"
-[profile.release]
-lto = true
-codegen-units = 1
-panic = "abort"
-
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O4']
diff --git a/tools/benchmark-hooks/Makefile.toml b/tools/benchmark-hooks/Makefile.toml
new file mode 100644
index 00000000000..05982c75323
--- /dev/null
+++ b/tools/benchmark-hooks/Makefile.toml
@@ -0,0 +1,4 @@
+[tasks.doc-test]
+private = true
+clear = true
+script = ["exit 0"]
diff --git a/tools/benchmark-struct/Cargo.toml b/tools/benchmark-struct/Cargo.toml
index 0769b567394..cc9b1b396fb 100644
--- a/tools/benchmark-struct/Cargo.toml
+++ b/tools/benchmark-struct/Cargo.toml
@@ -14,10 +14,5 @@ wasm-bindgen = "=0.2.78"
web-sys = { version = "0.3.55", features = ["Window"]}
yew = "0.19.3"
-[profile.release]
-lto = true
-codegen-units = 1
-panic = "abort"
-
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O4']
diff --git a/tools/benchmark-struct/Makefile.toml b/tools/benchmark-struct/Makefile.toml
new file mode 100644
index 00000000000..05982c75323
--- /dev/null
+++ b/tools/benchmark-struct/Makefile.toml
@@ -0,0 +1,4 @@
+[tasks.doc-test]
+private = true
+clear = true
+script = ["exit 0"]
diff --git a/tools/process-benchmark-results/Makefile.toml b/tools/process-benchmark-results/Makefile.toml
new file mode 100644
index 00000000000..05982c75323
--- /dev/null
+++ b/tools/process-benchmark-results/Makefile.toml
@@ -0,0 +1,4 @@
+[tasks.doc-test]
+private = true
+clear = true
+script = ["exit 0"]
diff --git a/tools/website-test/Cargo.toml b/tools/website-test/Cargo.toml
index 43d17d80a25..989de43d111 100644
--- a/tools/website-test/Cargo.toml
+++ b/tools/website-test/Cargo.toml
@@ -16,7 +16,7 @@ js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
weblog = "0.3.0"
-yew = { path = "../../packages/yew/", features = ["ssr"] }
+yew = { path = "../../packages/yew/", features = ["ssr", "hydration"] }
yew-router = { path = "../../packages/yew-router/" }
tokio = { version = "1.15.0", features = ["full"] }
diff --git a/website/docs/advanced-topics/server-side-rendering.md b/website/docs/advanced-topics/server-side-rendering.md
index d69dda3cbdc..47efa638f55 100644
--- a/website/docs/advanced-topics/server-side-rendering.md
+++ b/website/docs/advanced-topics/server-side-rendering.md
@@ -126,11 +126,76 @@ suspended.
With this approach, developers can build a client-agnostic, SSR ready
application with data fetching with very little effort.
+## SSR Hydration
+
+Hydration is the process that connects a Yew application to the
+server-side generated HTML file. By default, `ServerRender` prints
+hydratable string which includes comment nodes to facilitate hydration.
+When the `Renderer::hydrate` method is called, instead of start rendering from
+scratch, Yew will reconcliate the Virtual DOM generated by the application to
+the html string generated by the server renderer.
+
+:::caution
+
+To successfully hydrate an html representation created by the
+`ServerRenderer`, the client must produce a Virtual DOM layout that
+exactly matches the one used for SSR including components that does not
+contain any elements. If you have any component that is only useful in
+one implementation, you may want to use `PhantomComponent` to fill the
+position of the extra component.
+:::
+
+## Component Lifecycle during hydration
+
+During Hydration, components are created in a different order
+(top-to-bottom for siblings). After a component is created, it will be
+rendered at least twice before any effects are called. It is important to make
+sure your main function of the function component is side-effect free.
+It should not mutate any states or trigger additional renders. If your
+component currently mutates states or triggers additional renders, move
+them into an `use_effect` hook.
+
+:::danger Struct Components
+
+Whilst it's possible to use Struct Components with server-side rendering in
+hydration, the components will be created in a different order than they
+are directly rendered on the client-side. The render function will be called
+multiple times before the rendered function will be called and the
+component will start receiving messages immediately after the `view()`
+function is called after the first time. At that time, the DOM may not
+be connected, you should prevent any access to `NodeRef` until
+`rendered()` method is called.
+
+When using SSR and Hydration, prefer function components whenever
+possible.
+:::
+
+## Example
+
+```rust ,ignore
+use yew::prelude::*;
+use yew::Renderer;
+
+#[function_component]
+fn App() -> Html {
+ html! {{"Hello, World!"}
}
+}
+
+fn main() {
+ let renderer = Renderer::::new();
+
+ // hydrates everything under body element, removes trailing
+ // elements (if any).
+ renderer.hydrate();
+}
+```
+
Example: [simple\_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr)
+Example: [ssr\_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router)
:::caution
-Server-side rendering is experiemental and currently has no hydration support.
-However, you can still use it to generate static websites.
+Server-side rendering is currently experiemental. If you find a bug, please file
+an issue on [GitHub](https://github.com/yewstack/yew/issues/new?assignees=&labels=bug&template=bug_report.md&title=).
:::