You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit 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: Cursor Side A — unified diff (full patch): 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 `
` 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 `
`: 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, - "form_id": ids.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, - "form_id": ids.form_id, - })).unwrap()); - input type="text" id=(ids.tag_input_id) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" required placeholder=(tag_placeholder); - @if let Some(tid) = ids.text_input_id { - textarea id=(tid) name="text" rows="4" placeholder=(text_placeholder) required {} - } @else { - textarea name="text" rows="4" placeholder=(text_placeholder) required {} - } - p { button type="submit" { (submit_label) } } - } - } @else { - form id=(ids.form_id) method="POST" action="/ui" { - 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"}, - })).unwrap()); - input type="text" id=(ids.tag_input_id) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" placeholder=(tag_placeholder); - @if let Some(tid) = ids.text_input_id { - textarea id=(tid) name="text" rows="4" placeholder=(text_placeholder) {} - } @else { - textarea name="text" rows="4" placeholder=(text_placeholder) {} - } - p { button type="submit" { (submit_label) } } - } + "error_target": ERRORS_ID, + "form_id": FORM_ID, + })).unwrap()); + input type="text" id=(TAG_INPUT_ID) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" required placeholder="thread-topic-slug-here"; + textarea name="text" rows="4" placeholder="First post body…" required {} + p { button type="submit" { "create thread / post" } } } } } } -fn new_thread_form_public(show: bool) -> Markup { - if !show { - return html! {}; - } - new_thread_compose_section("public", &PUBLIC_IDS, NewThreadComposeKind::Public) -} - -fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { - if !show { - return html! {}; - } - // Single root for Idiomorph when morphing `#room-new-thread-ui-slot` (expanded has form + section). +/// Inner fragment morphed into `#new-thread-ui-slot` (Idiomorph replaces children; outer `id` stays). +fn new_thread_slot_inner(nav: &ThreadNav, compose_expanded: bool) -> Markup { html! { - div class="room-new-thread-slot-inner" { + div class="new-thread-slot-inner" { @if compose_expanded { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetNewThreadComposeExpanded { room_wire: nav.room_wire.clone(), expanded: false, }).expect("static json")); @@ -126,10 +54,10 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) "-" } } - (new_thread_compose_section(&nav.room_wire, &ROOM_IDS, NewThreadComposeKind::Room)) + (new_thread_compose_section(&nav.room_wire)) } @else { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetNewThreadComposeExpanded { room_wire: nav.room_wire.clone(), expanded: true, }).expect("static json")); @@ -142,16 +70,19 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) } } -pub(crate) fn login_to_post_hint_markup() -> Markup { - html! { - p class="muted" { "log in to post" } +pub(crate) fn new_thread_slot_markup(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { + if !show { + return html! {}; } + new_thread_slot_inner(nav, compose_expanded) } -pub(crate) fn fragment_public_new_thread_form(show: bool) -> Markup { - new_thread_form_public(show) +pub(crate) fn fragment_new_thread_slot(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { + new_thread_slot_markup(nav, show, compose_expanded) } -pub(crate) fn fragment_room_new_thread_form(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { - new_thread_form_for_room(nav, show, compose_expanded) +pub(crate) fn login_to_post_hint_markup() -> Markup { + html! { + p class="muted" { "log in to post" } + } } diff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs index be08491b893f5960723e77c7f7fb9d416100f0fb..f9c425634d1dfa2cbbc2e22c3c745717a6486173 100644 --- a/server/src/html/forum/views.rs +++ b/server/src/html/forum/views.rs @@ -19,7 +19,7 @@ use super::access::user_can_view_room; use super::feed::{collect_thread_rows_for_scope, render_thread_feed}; use super::ingest::ingest_entry_markup; use super::nav::ThreadNav; -use super::new_thread::fragment_room_new_thread_form; +use super::new_thread::fragment_new_thread_slot; use super::page::{auth_strip, bc_room}; use super::paginator::{render_thread_paginator, PAGE_SIZE}; use super::room_members::room_members_section_markup; @@ -276,8 +276,8 @@ pub async fn room_page( h3 { "threads" } (render_thread_feed(Some(&nav), "room-thread-feed", &rows, now)) @if show_new { - div id="room-new-thread-ui-slot" { - (fragment_room_new_thread_form(&nav, true, false)) + div id="new-thread-ui-slot" { + (fragment_new_thread_slot(&nav, true, false)) } } (cli_panel(&[forum_cli, garden_cli, audit_cli])) diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 6617781a2e8e86c2e2693788ea7cd0eb0e3659a2..e142240c95c8e92c22ab631df98268253cf0cbdf 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -27,9 +27,9 @@ pub use forum::{ }; pub(crate) use forum::{ - fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup, - room_members_section_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, - thread_ui_expand_redacted_post, user_can_post_room, user_can_view_room, + fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup, + thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post, + user_can_post_room, user_can_view_room, }; pub use garden::{garden_index, ontology_path, room_garden_index, room_ontology_path}; pub use search::{search_page, search_results_fragment}; @@ -183,7 +183,7 @@ impl JsBuilder { ) } - /// Morph **children** of `selector` so the outer element (e.g. `#room-new-thread-ui-slot`) keeps its `id`. + /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`. pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self { self.qs(selector).morph_inner(markup) } diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs index 0c4d33e58ad2bff9661bf70ec994ac30f7ca3efe..5031ebfeb928f28e471f23210b8c644654adb7c7 100644 --- a/server/src/html/ui_action.rs +++ b/server/src/html/ui_action.rs @@ -38,10 +38,9 @@ pub enum HtmlUiAction { RedactPost { post_id: String, }, - /// Morph `#public-new-thread-ui-slot` to the new-thread form (or login hint). - ExpandPublicNewThreadForm, - /// Morph `#room-new-thread-ui-slot` for the given room wire id. - ExpandRoomNewThreadForm { + /// Morph `#new-thread-ui-slot` inner to the collapsed compose toggle (or login hint). + /// Use `room_wire: "public"` for the public forum home; otherwise a private room id (`short/slug`). + ExpandNewThreadForm { room_wire: String, }, /// Morph `#room-members-section` — members list open or collapsed (server-rendered). @@ -50,8 +49,8 @@ pub enum HtmlUiAction { #[serde(default)] expanded: bool, }, - /// Morph `#room-new-thread-ui-slot` — compose body open or collapsed (server-rendered). - SetRoomNewThreadComposeExpanded { + /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home). + SetNewThreadComposeExpanded { room_wire: String, #[serde(default)] expanded: bool, @@ -133,15 +132,23 @@ mod tests { } #[test] - fn expand_public_unit_variant() { - let template = serde_json::json!({ "action": "expand_public_new_thread_form" }); + fn expand_new_thread_form_public() { + let template = serde_json::json!({ + "action": "expand_new_thread_form", + "room_wire": "public", + }); let mut form = HashMap::new(); form.insert( UI_RPC_FIELD.to_string(), serde_json::to_string(&template).unwrap(), ); let a = parse_html_ui_from_form(&form).unwrap(); - assert_eq!(a, HtmlUiAction::ExpandPublicNewThreadForm); + assert_eq!( + a, + HtmlUiAction::ExpandNewThreadForm { + room_wire: "public".into(), + } + ); } #[test] @@ -190,9 +197,9 @@ mod tests { } #[test] - fn set_room_new_thread_compose_expanded_true() { + fn set_new_thread_compose_expanded_true() { let template = serde_json::json!({ - "action": "set_room_new_thread_compose_expanded", + "action": "set_new_thread_compose_expanded", "room_wire": "ab/cd", "expanded": true, }); @@ -204,7 +211,7 @@ mod tests { let a = parse_html_ui_from_form(&form).unwrap(); assert_eq!( a, - HtmlUiAction::SetRoomNewThreadComposeExpanded { + HtmlUiAction::SetNewThreadComposeExpanded { room_wire: "ab/cd".into(), expanded: true, } diff --git a/test/browser_sse.clj b/test/browser_sse.clj index 3ac71a2986cbb71ed2c513f50cd5ef3f2582c945..8bb064dbc2dc0feb6caaa67f520a461645958277 100644 --- a/test/browser_sse.clj +++ b/test/browser_sse.clj @@ -80,19 +80,17 @@ room-url (str base-url "/r/" room-short "/" room-slug) thread-url (str room-url "/t/sse-thread")] ;; Object under test: slug_ui.js intercepts POST /ui, evals JS, morphs - ;; #room-new-thread-ui-slot (expand compose), then post_ingest redirects to thread. + ;; #new-thread-ui-slot (expand compose), then post_ingest redirects to thread. (page/navigate alice-pg room-url) (page/wait-for-load-state alice-pg :load) - (is (wait-for-text alice-pg "#room-new-thread-ui-slot" - "new thread in this room" 30000) + (is (wait-for-text alice-pg "#new-thread-ui-slot" "+" 30000) "collapsed new-thread control in slot (page + slug_ui.js)") - (locator/click (page/locator alice-pg "#room-new-thread-ui-slot button.form-toggle")) - (is (wait-for-text alice-pg "#room-new-thread-ui-slot" - "hide new thread form" 30000) + (locator/click (page/locator alice-pg "#new-thread-ui-slot button.form-toggle")) + (is (wait-for-text alice-pg "#new-thread-compose" "create thread / post" 30000) "compose expanded via POST /ui morph (slug_ui.js)") - (locator/fill (page/locator alice-pg "#room-new-tag") "sse-thread") - (locator/fill (page/locator alice-pg "#room-new-thread-compose textarea") "seed thread") - (locator/click (page/locator alice-pg "#room-new-thread-form button[type='submit']")) + (locator/fill (page/locator alice-pg "#new-thread-tag") "sse-thread") + (locator/fill (page/locator alice-pg "#new-thread-compose textarea") "seed thread") + (locator/click (page/locator alice-pg "#new-thread-form button[type='submit']")) (page/wait-for-url alice-pg thread-url {:timeout 90000.0}) (page/wait-for-load-state alice-pg :load) (is (wait-for-text alice-pg "#thread-feed-region" "seed thread" 45000) Side B — contributor: tommy-mor Side B — commit 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 Side B — unified diff (full patch): 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() }