Repository source: docs/plans/production_slow_actions_slice2_optimistic_reactions_plan_v1.md
Production Slow Actions Slice 2 Optimistic Reactions Plan V1
This plan breaks out Slice 2 from production_slow_actions_client_responsiveness_plan_v1.md. The goal is to make thread and post reaction clicks feel immediate while preserving server-side canonical writes, authorization, deduplication, score calculation, and hidden-state decisions.
Goal
Make these actions update visible UI immediately after browser identity is ready:
data-action="apply-thread-tag"data-action="apply-post-tag"
The server remains authoritative. The client only applies speculative UI state, then reconciles with /api/apply_thread_tag or /api/apply_post_tag.
Non-Goals
Do not change canonical record formats.
Do not change
LocalWriteService::applyThreadTag()orLocalWriteService::applyPostTag()semantics.Do not add undo records or delete reaction records.
Do not make anonymous reactions appear successful.
Do not optimistically update server-owned approval-sensitive values as final truth.
Do not implement optimistic compose/reply/thread creation in this slice.
Current State
public/assets/thread_reactions.jshandles both thread and post reaction clicks.The code already disables the clicked button before network work starts.
The code waits for
ensureReactionIdentity()before sending the request.The code currently waits for the server response before changing final visible state:
button label
aria-pressedthread score text
post hidden state
final feedback text
Slice 1 instrumentation added passive action marks, parsed
Server-Timing, and opt-in[forum timing]debug output.
Desired User Experience
After identity is ready:
the clicked button should immediately look applied
feedback should immediately indicate a pending save, not leave the user waiting
thread likes should update visible score optimistically when the client can safely infer the visible delta
post flags may visually mark pending, but final hiding should wait for server confirmation unless the rollback UX is explicit
duplicate or no-op server responses should settle into the same final applied UI
failures should restore the prior UI and show the existing error feedback
Integrity Rules
The server remains authoritative for:
whether the viewer is approved
whether the reaction is a duplicate
thread score totals
post score totals
approved flag counts
hidden state
canonical commit success
The client may only predict UI. It must reconcile server fields:
score_totalpost_score_totalapproved_flag_countis_hiddenviewer_is_approvedwrote_recordcommit_sha
A failed request must restore the previous button text, disabled state,
aria-pressed, thread score text, and post visibility.Pending styling must not imply canonical success if the request is still in flight.
Implementation Slices
Slice 2A: Extract Reaction UI State Helpers
Status:
implemented
added explicit helpers to capture, restore, and confirm thread/post reaction state
rollback now restores button state, thread score text, and post visibility from captured state
this slice is an internal structure change only; optimistic UI changes begin in Slice 2B
Goal:
make optimistic apply and rollback explicit and testable
Work:
add helper functions in
thread_reactions.jsfor:capturing previous button/root/score/feedback state
applying pending button state
applying final server-confirmed state
rolling back on failure
keep behavior equivalent at first except for internal structure
avoid duplicating thread and post reaction code where simple shared helpers fit
Candidate helpers:
captureButtonState(button)restoreButtonState(button, state)setPendingReactionButton(button, appliedLabel)setConfirmedReactionButton(button, appliedLabel)captureThreadReactionState(root, button, scoreNode, feedbackNode)capturePostReactionState(root, button, feedbackNode)
Acceptance:
existing reaction tests still pass before optimistic behavior changes
rollback helper can restore button label,
aria-pressed, disabled state, score text, and root hidden state
Slice 2B: Optimistic Thread Tag UI
Status:
implemented
thread tag buttons switch to the applied label and
aria-pressed="true"immediately after identity readinesstag=likeincrements a parseable visible thread score optimistically when the button was not already pressedserver responses still reconcile the authoritative
score_total,wrote_record, and final feedbackserver failures restore the captured button and score state
Goal:
make thread tag clicks visibly apply immediately after identity readiness
Work:
after
ensureReactionIdentity()succeeds, apply pending thread state before fetch:set button text to applied label
set
aria-pressed="true"keep button disabled while request is in flight
set feedback to a pending message such as
Saving tag...
optimistically adjust score only when a safe local assumption exists:
initially only for
tag=likeonly if the button was not already pressed
parse current
Score: Ntextincrement by 1 as a temporary display
on success:
replace optimistic score with server
score_totalkeep applied button state
show
Liked.orAlready liked.usingwrote_record
on failure:
restore captured score and button state
show existing error feedback
Acceptance:
test proves button text and
aria-pressedchange before the fetch promise resolvestest proves optimistic score appears before response and final score reconciles to server value
test proves failed server response rolls back score and button state
existing duplicate and unapproved-user behavior still reconciles to server response
Slice 2C: Optimistic Post Tag UI
Status:
implemented
post tag buttons switch to the applied label and
aria-pressed="true"immediately after identity readinessvisible feedback changes to
Saving tag...while the request is in flightposts are not hidden optimistically;
root.hiddenchanges only after the server returnsis_hidden=yesserver failures restore the captured button and post visibility state
Goal:
make post reactions visibly apply immediately after identity readiness without prematurely hiding content
Work:
after
ensureReactionIdentity()succeeds, apply pending post state before fetch:set button text to applied label
set
aria-pressed="true"keep button disabled while request is in flight
set feedback to pending copy
do not hide the post before server confirmation in the first implementation
hiding depends on server approval-sensitive state
premature hide has a high rollback cost and can be added later with an explicit pending-hidden affordance
on success:
keep applied button state
hide root only when server returns
is_hidden=yesshow existing final feedback for visible posts
on failure:
restore captured button and visibility state
show existing error feedback
Acceptance:
test proves post reaction button changes before fetch resolves
test proves server
is_hidden=yeshides only after responsetest proves failed server response restores button and root visibility
Slice 2D: Pending Operation Memory
Status:
implemented
added page-local pending operation memory keyed as
thread:${threadId}:${tag}andpost:${postId}:${tag}identical operations are ignored while the first request is in flight, even if triggered through a separate matching button
pending keys are cleared in
finallyafter both success and failure so retries can proceed
Goal:
prevent repeated clicks or page-local double application while a request is in flight
Work:
add a small in-memory pending map keyed by:
thread:${threadId}:${tag}post:${postId}:${tag}
if an identical operation is already pending, ignore additional clicks
remove the pending key on success or failure
do not persist to session storage in this slice unless tests reveal reload hazards
Acceptance:
repeated fast clicks issue one fetch
pending key is cleared after success
pending key is cleared after failure and retry works
Slice 2E: Timing and Debug Compatibility
Status:
implemented
forum_first_feedbacknow marks the optimistic pending UI update after identity readiness, instead of the earlier identity-preparation messageduplicate pending clicks emit opt-in debug summaries with status
ignored_pendingexisting success and error summaries continue to use
okanderrorreaction responses continue to parse
Server-Timinginto the opt-in debug payload
Goal:
keep Slice 1 timing useful after optimistic behavior lands
Work:
ensure
forum_first_feedbackmarks the optimistic visible update, not the later server reconciliationkeep
forum_fetch_start,forum_response_received,forum_reconcile_complete, andforum_action_completeadd debug summary status values:
okerrorignored_pending
continue parsing
Server-Timingfrom reaction responses
Acceptance:
debug timing still prints only when explicitly enabled
no request body, post body, key material, or authored content appears in debug output
browser tests cover at least one optimistic success and one rollback
Test Plan
Use the existing Node-backed BrowserSigningNormalizationTest harness.
Add or update tests for:
thread reaction applies optimistic UI before fetch resolves
thread reaction final score reconciles to server
score_totalthread reaction failure rolls back button and score
post reaction applies optimistic button state before fetch resolves
post reaction hides only after
is_hidden=yesresponseduplicate fast click produces one fetch while pending
timing marks still include first feedback, fetch, response, reconcile, and action complete
Run:
node --check public/assets/thread_reactions.jsphp tests/run.php
Recommended Commit Breakdown
Refactor reaction state helpers with no intended behavior change.
Add optimistic thread tag behavior and tests.
Add optimistic post tag behavior and tests.
Add pending-operation dedupe and timing/debug compatibility tests.
Update this plan with final implementation status.
Open Questions
Should unapproved users see a different optimistic score path? Initial recommendation: no local score increment unless the client has a reliable approved-viewer signal.
Should post flags hide optimistically? Initial recommendation: no; wait for
is_hidden=yesfrom the server.Should pending operations survive reloads? Initial recommendation: no for this slice; reconciliation across reloads belongs with broader optimistic operation storage.
Completion Criteria
This slice is complete when reaction clicks feel immediate after identity readiness, server responses still reconcile all authoritative values, failures roll back cleanly, and the existing non-JavaScript/server behavior remains unchanged.