Side B is a small, targeted concurrency fix that prevents readers from stalling behind pending writers on a hot page render path, a real correctness/performance bug with clear rationale. Side A is a larger refactor unifying DOM ids/actions across public/room forms, which improves clarity but is mostly renaming/restructuring churn with no bugfix, and carries more risk of regressions across multiple files and tests.
constitution · epochs · watch · epoch 3
c_01304e7d0f46 (tommy-mor) vs c_8337e9dd0f87 (tommy-mor)
download prompt · raw event · cmp_69759845421875
council reasoning
B fixes a real concurrency hazard by precomputing room_members_section_markup and dropping the Tokio RwLock read guard before layout/HTML stringify, avoiding reader stall behind writers during ingest. A is a worthwhile DOM/action unify of public vs room new-thread forms, but mostly structural dedup and selector renames without the same correctness impact.
Side B fixes a concrete concurrency issue by precomputing `room_members_section_markup`, dropping the `tokio::sync::RwLock` read guard before expensive page layout/stringification, and avoiding reader stalls when a writer is waiting. Side A is primarily a broad refactor that unifies public and room new-thread UI IDs, actions, and markup; while it reduces duplication and standardizes behavior, it is mostly structural and also changes UI behavior rather than addressing a clear correctness or performance problem.
sides
A — c_01304e7d0f46 (tommy-mor)
message
[d88719a1] refactor(html): unify public and room new-thread forms
- Single DOM contract: #new-thread-ui-slot, #new-thread-compose, #new-thread-form,
#new-thread-tag; shared check_ingest + post_ingest error wiring for all scopes.
- Replace ExpandPublic/ExpandRoom/SetRoomNewThreadComposeExpanded with
ExpandNewThreadForm { room_wire } and SetNewThreadComposeExpanded { room_wire, expanded };
room_wire "public" covers the home toolbar flow.
- SSE refresh resets #new-thread-compose form for any room_key.
- Browser test selectors updated for the unified ids.
Made-with: Cursordiff preview
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 03f4f7efe08df3ec160e50d08652bde7d6a9c4d2..422b520f527f64571f86f0ee3cb57edcbc21c0c3 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -63,11 +63,7 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str
crate::html::thread_feed_region_markup(state, Some(room_key), thread_id, None).await;
let builder = JsBuilder::new().morph_selector(&format!("#{feed_id}"), feed_markup);
- let builder = if room_key == "public" {
- builder.qs("#public-new-thread-compose form").reset()
- } else {
- builder.qs("#room-new-thread-compose form").reset()
- };
+ let builder = builder.qs("#new-thread-compose form").reset();
let builder = builder.if_current_path_matches(&thread_url, |builder| {
builder.morph_selector("#thread-feed-region", thread_feed_markup)
});
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 6ff9f1319e91f61bf8546eb65637c20f3a58483c..e979053ff1ba8c0e2bf55add0a32b7de11cf1e56 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -19,7 +19,7 @@ use crate::{
},
canonical_path::canonicalize_tag,
html::{
- fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,
+ fragment_new_thread_slot, login_to_post_hint_markup,
parse_html_ui_from_form, room_members_section_markup, thread_feed_html,
thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post,
thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room,
@@ -139,24 +139,24 @@ async fn dispatch_ui_action(
}
}
}
- HtmlUiAction::ExpandPublicNewThreadForm => {
- let reduced = state.reduced.read().await;
- let user = session.map(|s| s.username.as_str());
- drop(reduced);
- let markup = if user.is_some() {
- fragment_public_new_thread_form(true)
- } else {
- login_to_post_hint_markup()
- };
- JsBuilder::new()
- .morph_inner_selector("#public-new-thread-ui-slot", markup)
- .into_response()
- }
- HtmlUiAction::ExpandRoomNewThreadForm { room_wire } => {
+ HtmlUiAction::ExpandNewThreadForm { room_wire } => {
let room_wire = room_wire.trim().to_string();
if room_wire.is_empty() {
return ui_js_warn("missing room").into_response();
}
+ if room_wire == "public" {
+ let reduced = state.reduced.read().await;
+ let user = session.map(|s| s.username.as_str());
+ drop(reduced);
+ let markup = if user.is_some() {
+ fragment_new_thread_slot(&ThreadNav::public(), true, false)
+ } else {
+ login_to_post_hint_markup()
+ };
+ return JsBuilder::new()
+ .morph_inner_selector("#new-thread-ui-slot", markup)
+ .into_response();
+ }
let reduced = state.reduced.read().await;
let user = session.map(|s| s.username.as_str());
if !reduced.rooms.contains(&room_wire) {
@@ -176,12 +176,12 @@ async fn dispatch_ui_action(
return ui_js_warn("bad room").into_response();
};
let markup = if can_post {
- fragment_room_new_thread_form(&nav, true, false)
+ fragment_new_thread_slot(&nav, true, false)
} else {
login_to_post_hint_markup()
};
JsBuilder::new()
- .morph_inner_selector("#room-new-thread-ui-slot", markup)
+ .morph_inner_selector("#new-thread-ui-slot", markup)
.into_response()
}
HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => {
@@ -205,11 +205,28 @@ async fn dispatch_ui_action(
.morph_selector("#room-members-section", markup)
.into_response()
}
- HtmlUiAction::SetRoomNewThreadComposeExpanded { room_wire, expanded } => {
+ HtmlUiAction::SetNewThreadComposeExpanded { room_wire, expanded } => {
let room_wire = room_wire.trim().to_string();
if room_wire.is_empty() {
return ui_js_warn("missing room").into_response();
}
+ if room_wire == "public" {
+ let reduced = state.reduced.read().await;
+ let _user = session.map(|s| s.username.as_str());
+ drop(reduced);
+ let can_post = session.is_some();
+ let nav = ThreadNav::public();
+ let markup = if can_post {
+ fragment_new_thread_slot(&nav, true, expanded)
+ } else {
+ login_to_post_hint_markup()
+ };
+ let mut b = JsBuilder::new().morph_inner_selector("#new-thread-ui-slot", markup);
+ if expanded && can_post {
+ b = b.focus_selector("#new-thread-tag");
+ }
+ return b.into_response();
+ }
let reduced = state.reduced.read().await;
let user = session.map(|s| s.username.as_str());
if !reduced.rooms.contains(&room_wire) {
@@ -229,13 +246,13 @@ async fn dispatch_ui_action(
return ui_js_warn("bad room").into_response();
};
let markup = if can_post {
- fragment_room_new_thread_form(&nav, true, expanded)
+ fragment_new_thread_slot(&nav, true, expanded)
} else {
login_to_post_hint_markup()
};
- let mut b = JsBuilder::new().morph_inner_selector("#room-new-thread-ui-slot", markup);
+ let mut b = JsBuilder::new().morph_inner_selector("#new-thread-ui-slot", markup);
if expanded && can_post {
- b = b.focus_selector("#room-new-tag");
+ b = b.focus_selector("#new-thread-tag");
}
b.into_response()
}
diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs
index 30b95ff6e85245a82199c96e41484d03c24989dd..1b4ae7baa3ad4757b74172d7b67f3f2b33d1075d 100644
--- a/server/src/html/forum/feed.rs
+++ b/server/src/html/forum/feed.rs
@@ -245,11 +245,13 @@ pub async fn home(
p class="muted" { "dark = time-ordered · light = vote-ranked" }
div class="thread-feed-toolbar" {
form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect("static json"));
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandNewThreadForm {
+ room_wire: "public".into(),
+ }).expect("static json"));
button type="submit" class="section-add-btn" { "+" }
}
}
- div id="public-new-thread-ui-slot" {}
+ div id="new-thread-ui-slot" {}
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
(cli_panel(&["npx slugsocial public forum list"]))
},
diff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs
index 8fbf3b71f41fafb474999ebb3d622e6bd5b5f313..bd60703924eee5b0e5a7f69c0a2dc526442512a2 100644
--- a/server/src/html/forum/mod.rs
+++ b/server/src/html/forum/mod.rs
@@ -20,9 +20,7 @@ pub use profile::user_profile_page;
pub use views::{room_page, room_thread_view, thread_view};
pub(crate) use access::{user_can_post_room, user_can_view_room};
-pub(crate) use new_thread::{
- fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,
-};
+pub(crate) use new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};
pub(crate) use room_members::room_members_section_markup;
pub(crate) use thread_morph::{
thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post,
diff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs
index b2d24b8ab3ec7eee0edd16bed8ee6605e49bf408..4dd2654f953a7db6ee9b25c348f99906da93a94a 100644
--- a/server/src/html/forum/new_thread.rs
+++ b/server/src/html/forum/new_thread.rs
@@ -5,120 +5,48 @@ use serde_json::json;
use super::nav::ThreadNav;
-struct NewThreadIds {
- compose_section_id: &'static str,
- errors_id: &'static str,
- form_id: &'static str,
- tag_input_id: &'static str,
- text_input_id: Option<&'static str>,
-}
-
-const PUBLIC_IDS: NewThreadIds = NewThreadIds {
- compose_section_id: "public-new-thread-compose",
- errors_id: "public-new-thread-errors",
- form_id: "public-new-thread-form",
- tag_input_id: "new-thread-tag",
- text_input_id: Some("new-thread-text"),
-};
-
-const ROOM_IDS: NewThreadIds = NewThreadIds {
- compose_section_id: "room-new-thread-compose",
- errors_id: "room-new-thread-errors",
- form_id: "room-new-thread-form",
- tag_input_id: "room-new-tag",
- text_input_id: None,
-};
-
-#[derive(Clone, Copy)]
-enum NewThreadComposeKind {
- /// Home page: no client-side check RPC; post template omits `error_target` / `form_id`.
- Public,
- /// Room page: `check_ingest` + error targets on post (matches thread compose).
- Room,
-}
-
-/// Shared `<section class="compose">` for creating a thread + first post.
-fn new_thread_compose_section(room_wire: &str, ids: &NewThreadIds, kind: NewThreadComposeKind) -> Markup {
- let client_check = matches!(kind, NewThreadComposeKind::Room);
- let (tag_placeholder, text_placeholder, submit_label) = match kind {
- NewThreadComposeKind::Public => (
- "thread-title-slug-here",
- "Hello threadgoers!! Behold my new thread!",
- "create thread / make first post",
- ),
- NewThreadComposeKind::Room => (
- "thread-topic-slug-here",
- "First post body…",
- "post",
- ),
- };
+/// Stable ids shared by public home and private room “new thread” UI (`#new-thread-ui-slot`).
+const COMPOSE_SECTION_ID: &str = "new-thread-compose";
+const ERRORS_ID: &str = "new-thread-errors";
+const FORM_ID: &str = "new-thread-form";
+const TAG_INPUT_ID: &str = "new-thread-tag";
+/// Shared `<section class="compose">`: check + post with `error_target` / `form_id` (same as thread reply compose).
+fn new_thread_compose_section(room_wire: &str) -> Markup {
html! {
- section class="compose" id=(ids.compose_section_id) {
- div id=(ids.errors_id) {}
- @if client_check {
- form id=(ids.form_id) method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(template_json_compact(&json!({
- "action": "check_ingest",
+ section class="compose" id=(COMPOSE_SECTION_ID) {
+ div id=(ERRORS_ID) {}
+ form id=(FORM_ID) method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(template_json_compact(&json!({
+ "action": "check_ingest",
+ "room": room_wire,
+ "thread_tag": {"$form": "thread_tag"},
+ "text": {"$form": "text"},
+ "error_target": ERRORS_ID,
+ "form_id": FORM_ID,
+ })).unwrap()) {
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({
+ "action": "post_ingest",
"room": room_wire,
"thread_tag": {"$form": "thread_tag"},
"text": {"$form": "text"},
- "error_target": ids.errors_id,
-
… preview truncated; 13,556 characters omittedB — c_8337e9dd0f87 (tommy-mor)
message
[d91e9431] fix(server): drop reduced read lock before room page layout Precompute room_members_section_markup under the guard, then release before layout/html stringify. Tokio RwLock queues new readers behind a waiting writer; holding the read guard across rendering could stall navigations when ingests take the write lock. Made-with: Cursor
diff preview
diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs
index f6e45b05bb92126966e304619f80ef1d45be7a4b..a2d36c0645cd5cdd97da78151a2fd574175e67f8 100644
--- a/server/src/html/forum.rs
+++ b/server/src/html/forum.rs
@@ -965,18 +965,23 @@ pub async fn room_page(
drop(reduced);
return (StatusCode::NOT_FOUND, "room not found").into_response();
};
- let slug_display = room_slug.as_str();
+ // Precompute markup that needs `&ReducerState`, then release the lock before `layout`.
+ // `tokio::sync::RwLock` queues new readers behind a waiting writer; holding the read
+ // guard across HTML/stringify could stall other navigations when an ingest needs a write lock.
+ let members_markup = room_members_section_markup(&reduced, &room_id, false);
let forum_cli = format!("npx slugsocial private {room_id} forum list");
let garden_cli = format!("npx slugsocial private {room_id} garden tree");
let audit_cli = format!("npx slugsocial private {room_id} audit");
+ drop(reduced);
+ let slug_display = room_slug.as_str();
let page = layout(
&format!("room {slug_display} — slug.social"),
"view-thread",
html! {
(strip)
nav class="breadcrumb" { (bc_room(&nav, slug_display, None)) }
- (room_members_section_markup(&reduced, &room_id, false))
+ (members_markup)
h3 { "threads" }
(render_thread_feed(Some(&nav), "room-thread-feed", &rows, now))
@if show_new {
@@ -992,7 +997,6 @@ pub async fn room_page(
theme_from_jar(&jar),
&theme_next_from_uri(&uri),
);
- drop(reduced);
Html(page.into_string()).into_response()
}
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.