Skip to content

Commit cbea971

Browse files
committed
pull out validation logic
1 parent 672a819 commit cbea971

File tree

16 files changed

+1672
-579
lines changed

16 files changed

+1672
-579
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ members = [
6565
"packages/playwright-tests/fullstack-spread",
6666
"packages/playwright-tests/fullstack-routing",
6767
"packages/playwright-tests/fullstack-hydration-order",
68+
"packages/playwright-tests/fullstack-hydration-recovery",
6869
"packages/playwright-tests/suspense-carousel",
6970
"packages/playwright-tests/nested-suspense",
7071
"packages/playwright-tests/cli-optimization",

packages/autofmt/src/writer.rs

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ use std::{
1010
};
1111
use syn::{spanned::Spanned, token::Brace, Expr};
1212

13+
fn source_prefix_before_column(line: &str, column: usize) -> &str {
14+
line.get(..column.saturating_sub(1)).unwrap_or("")
15+
}
16+
1317
#[derive(Debug)]
1418
pub struct Writer<'a> {
1519
pub raw_src: &'a str,
@@ -599,10 +603,14 @@ impl<'a> Writer<'a> {
599603

600604
if brace_line != attr_line {
601605
// Get the raw line of the attribute
602-
let line = self.src.get(attr_line - 1).unwrap_or(&"");
606+
let line = attr_line
607+
.checked_sub(1)
608+
.and_then(|index| self.src.get(index))
609+
.copied()
610+
.unwrap_or("");
603611

604612
// Only write comments if the line is empty before the attribute start
605-
let row_start = line.get(..attr_span.start().column - 1).unwrap_or("");
613+
let row_start = source_prefix_before_column(line, attr_span.start().column);
606614
if !row_start.trim().is_empty() {
607615
return Ok(());
608616
}
@@ -1168,15 +1176,13 @@ impl<'a> Writer<'a> {
11681176
}
11691177

11701178
fn leading_row_is_empty(&self, location: LineColumn) -> bool {
1171-
let Some(line) = self.src.get(location.line - 1) else {
1172-
return false;
1173-
};
1174-
1175-
let Some(sub) = line.get(..location.column - 1) else {
1179+
let Some(line) = location.line.checked_sub(1).and_then(|index| self.src.get(index)) else {
11761180
return false;
11771181
};
11781182

1179-
sub.trim().is_empty()
1183+
source_prefix_before_column(line, location.column)
1184+
.trim()
1185+
.is_empty()
11801186
}
11811187

11821188
#[allow(clippy::map_entry)]
@@ -1281,3 +1287,40 @@ impl<'a> Writer<'a> {
12811287
false
12821288
}
12831289
}
1290+
1291+
#[cfg(test)]
1292+
mod tests {
1293+
use super::*;
1294+
use quote::quote_spanned;
1295+
use syn::parse::Parser;
1296+
1297+
#[test]
1298+
fn source_prefix_before_column_handles_zero_column() {
1299+
assert_eq!(source_prefix_before_column("class: \"value\"", 0), "");
1300+
}
1301+
1302+
#[test]
1303+
fn source_prefix_before_column_matches_existing_prefix_behavior() {
1304+
assert_eq!(source_prefix_before_column("class: \"value\"", 3), "cl");
1305+
}
1306+
1307+
#[test]
1308+
fn leading_row_is_empty_handles_zero_column() {
1309+
let writer = Writer::new("class: \"value\"", IndentOptions::default());
1310+
assert!(writer.leading_row_is_empty(LineColumn { line: 1, column: 0 }));
1311+
}
1312+
1313+
#[test]
1314+
fn write_rsx_call_handles_generated_call_site_spans() {
1315+
let body = CallBody::parse_strict
1316+
.parse2(quote_spanned!(Span::call_site()=> div {
1317+
class: "value",
1318+
p { "child" }
1319+
}))
1320+
.unwrap();
1321+
1322+
let mut writer = Writer::new("", IndentOptions::default());
1323+
writer.write_rsx_call(&body).unwrap();
1324+
assert!(writer.consume().is_some());
1325+
}
1326+
}

packages/html/src/lib.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,35 @@ pub mod extensions {
5959
pub use crate::attribute_groups::{GlobalAttributesExtension, SvgAttributesExtension};
6060
pub use crate::elements::extensions::*;
6161
}
62+
63+
/// HTML boolean attributes. These attributes are rendered without a value when
64+
/// truthy (e.g. `<input disabled>` rather than `<input disabled="true">`).
65+
pub const BOOL_ATTRS: &[&str] = &[
66+
"allowfullscreen",
67+
"allowpaymentrequest",
68+
"async",
69+
"autofocus",
70+
"autoplay",
71+
"checked",
72+
"controls",
73+
"default",
74+
"defer",
75+
"disabled",
76+
"formnovalidate",
77+
"hidden",
78+
"ismap",
79+
"itemscope",
80+
"loop",
81+
"multiple",
82+
"muted",
83+
"nomodule",
84+
"novalidate",
85+
"open",
86+
"playsinline",
87+
"readonly",
88+
"required",
89+
"reversed",
90+
"selected",
91+
"truespeed",
92+
"webkitdirectory",
93+
];
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// @ts-check
2+
const { test, expect } = require("@playwright/test");
3+
4+
const SERVER_URL = "http://localhost:7978";
5+
const HYDRATION_MISMATCH_MESSAGE = "[HYDRATION MISMATCH]";
6+
const HYDRATION_RECOVERY_MESSAGE =
7+
"Hydration mismatches detected. Falling back to a full client rebuild.";
8+
9+
async function waitForBuild(request) {
10+
for (let i = 0; i < 30; i++) {
11+
const response = await request.get(SERVER_URL);
12+
const text = await response.text();
13+
if (response.status() === 200 && text.includes('id="recovery-button"')) {
14+
return;
15+
}
16+
await new Promise((resolve) => setTimeout(resolve, 1000));
17+
}
18+
19+
throw new Error("Timed out waiting for the hydration recovery fixture to build");
20+
}
21+
22+
test("hydration mismatch recovers nested structure, text, attributes, and placeholders", async ({
23+
page,
24+
request,
25+
}) => {
26+
await waitForBuild(request);
27+
28+
const serverResponse = await request.get(SERVER_URL);
29+
expect(serverResponse.status()).toBe(200);
30+
31+
const serverHtml = await serverResponse.text();
32+
expect(serverHtml).toContain('id="recovery-button"');
33+
expect(serverHtml).toContain("Server text content");
34+
expect(serverHtml).toContain("Server placeholder content");
35+
expect(serverHtml).not.toContain('role="status"');
36+
expect(serverHtml).not.toContain('title="Client attribute title"');
37+
38+
const consoleMessages = [];
39+
const consoleErrors = [];
40+
const pageErrors = [];
41+
42+
page.on("console", (msg) => {
43+
consoleMessages.push(msg.text());
44+
if (msg.type() === "error") {
45+
consoleErrors.push(msg.text());
46+
}
47+
});
48+
page.on("pageerror", (error) => {
49+
pageErrors.push(error.message);
50+
});
51+
52+
await page.goto(SERVER_URL);
53+
await page.waitForLoadState("networkidle");
54+
55+
const mismatchMessages = () =>
56+
consoleMessages.filter((message) =>
57+
message.includes(HYDRATION_MISMATCH_MESSAGE),
58+
);
59+
const hasMismatch = (...fragments) =>
60+
mismatchMessages().some((message) =>
61+
fragments.every((fragment) => message.includes(fragment)),
62+
);
63+
64+
await expect
65+
.poll(() => mismatchMessages().length, {
66+
message: "expected one warning for each mismatch class",
67+
})
68+
.toBe(4);
69+
await expect
70+
.poll(
71+
() =>
72+
consoleMessages.filter((message) =>
73+
message.includes(HYDRATION_RECOVERY_MESSAGE),
74+
).length,
75+
{ message: "expected the hydration fallback warning to be logged once" },
76+
)
77+
.toBe(1);
78+
79+
expect(
80+
mismatchMessages().every(
81+
(message) =>
82+
message.includes("Reason:") &&
83+
message.includes("--- expected") &&
84+
message.includes("+++ actual") &&
85+
message.includes("@@"),
86+
),
87+
).toBeTruthy();
88+
89+
expect(
90+
hasMismatch(
91+
"Reason: Expected <strong>, found <span>.",
92+
"-strong {",
93+
"+span {",
94+
),
95+
).toBeTruthy();
96+
expect(
97+
hasMismatch(
98+
'Reason: Expected text "Client text content", found text "Server text content".',
99+
'- "Client text content",',
100+
'+ "Server text content",',
101+
),
102+
).toBeTruthy();
103+
expect(
104+
hasMismatch(
105+
"Reason: Expected <div> with attributes [role, title], but the DOM node is missing them.",
106+
'role: "status"',
107+
'title: "Client attribute title"',
108+
),
109+
).toBeTruthy();
110+
expect(
111+
hasMismatch(
112+
"Reason: Expected placeholder (comment node), found node type 1.",
113+
"VNode::placeholder()",
114+
"+ p {",
115+
),
116+
).toBeTruthy();
117+
118+
const recoveryButton = page.locator("#recovery-button");
119+
await expect(recoveryButton).toHaveCount(1);
120+
await expect(recoveryButton).toHaveText("Recovered 0");
121+
122+
const nestedLeaf = page.locator("#nested-leaf");
123+
await expect(nestedLeaf).toHaveCount(1);
124+
await expect(nestedLeaf).toHaveJSProperty("tagName", "STRONG");
125+
await expect(nestedLeaf).toHaveText("Nested client leaf");
126+
127+
const textMismatch = page.locator("#text-mismatch");
128+
await expect(textMismatch).toHaveText("Client text content");
129+
130+
const attributeMismatch = page.locator("#attribute-mismatch");
131+
await expect(attributeMismatch).toHaveAttribute("role", "status");
132+
await expect(attributeMismatch).toHaveAttribute(
133+
"title",
134+
"Client attribute title",
135+
);
136+
137+
await expect(page.locator("#placeholder-mismatch-shell p")).toHaveCount(0);
138+
await expect(page.locator("body")).not.toContainText("Server text content");
139+
await expect(page.locator("body")).not.toContainText(
140+
"Server placeholder content",
141+
);
142+
143+
await recoveryButton.click();
144+
await expect(recoveryButton).toHaveText("Recovered 1");
145+
await recoveryButton.click();
146+
await expect(recoveryButton).toHaveText("Recovered 2");
147+
148+
expect(pageErrors).toEqual([]);
149+
expect(consoleErrors).toEqual([]);
150+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "dioxus-playwright-fullstack-hydration-recovery-test"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[dependencies]
8+
dioxus = { workspace = true, features = ["fullstack"] }
9+
10+
[features]
11+
default = []
12+
server = ["dioxus/server"]
13+
web = ["dioxus/web"]

0 commit comments

Comments
 (0)