Skip to content

fix(timeline): a like was stored, then read back as zero everywhere - #811

Merged
github-actions[bot] merged 2 commits into
mainfrom
fix/reaction-read
Aug 28, 2026
Merged

fix(timeline): a like was stored, then read back as zero everywhere#811
github-actions[bot] merged 2 commits into
mainfrom
fix/reaction-read

Conversation

@catomean

@catomean catomean commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

The write path was repaired in #806 and #809 and the like still did not appear. It was in timeline_likes, timeline_event_stats.like_count read 1, and the post page rendered an empty, unpressed heart.

Nothing on the read side had ever populated these fields — in three separate places, all wrong in the same direction:

Path What it did
eventQueries hardcoded likesCount: 0, userLiked: false under a comment saying the UI enriched them later. Nothing did.
userFeeds called enrichEventsForDisplay, then overwrote its output with event.like_count / event.user_liked — columns timeline_events has never had — so each value became undefined || 0.
transformEnrichedEventToDisplay (followed feed, search) set no reaction fields at all. It is synchronous and the view carries no reaction columns, so it could not have.

That is why the counter never moved no matter which surface you looked at.

Reaction state is now resolved by one function. enrichEventsForDisplay calls it for every path that goes through enrichment; the three that cannot — they build rows from an RPC or from the view — call attachReactionState after mapping. Making this each caller's responsibility is precisely what produced three callers that all got it wrong.

Counts come from timeline_event_stats, the only place they live. "Did I react" is per-reader, so it reads the membership tables. Batched — three queries per page regardless of post count; per-post lookups would be 60 round-trips for a 20-post feed. A failure degrades to zeros rather than failing the feed.

Also: deleting the post you are looking at left it on screen

Found while verifying #806's delete fix in production. The delete succeeded — is_deleted true, deleted_at set — and the page went on rendering the post until a reload replaced it with "This post doesn't exist". The post page never passed onDelete, so PostCard called a callback nobody had supplied. Deleting a reply had the same gap.

The main post now navigates to the timeline (replace, so Back cannot return to a dead page). A deleted reply is removed from the thread at whatever depth it sat, and its parent's reply count follows it down.

🤖 Generated with Claude Code

https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5

catomean and others added 2 commits August 28, 2026 13:59
The write path was repaired in #806 and #809 and the like still did not
appear. It was in timeline_likes, timeline_event_stats.like_count read
1, and the post page rendered an empty, unpressed heart. Nothing on the
read side had ever populated these fields — in three separate places,
all wrong in the same direction:

  eventQueries hardcoded `likesCount: 0, userLiked: false` under a
  comment saying the UI enriched them later. Nothing did.

  userFeeds called enrichEventsForDisplay and then OVERWROTE its output
  with `event.like_count` / `event.user_liked` — columns timeline_events
  has never had — so each value was replaced by `undefined || 0` on its
  way to the screen.

  transformEnrichedEventToDisplay, used by the followed-users feed and
  by search, set no reaction fields at all. It is synchronous and the
  enriched view carries no reaction columns, so it could not have.

Three paths, one direction, which is why the counter never moved no
matter which surface you looked at.

Reaction state is now resolved by one function. enrichEventsForDisplay
calls it for every path that goes through enrichment; the three that
cannot — they build rows from an RPC or from the view — call
attachReactionState after mapping. Making this each caller's
responsibility is precisely what produced three callers that all got it
wrong.

Counts come from timeline_event_stats, which the reaction RPCs maintain
and which is the only place they live. "Did I react" cannot come from
there — it is per-reader — so it reads the membership tables. Batched:
three queries per page regardless of post count, alongside the existing
profile/project batching. Per-post lookups would be 60 round-trips for a
20-post feed.

A failure degrades to zeros rather than failing the feed. A post without
its counters is a small loss; a blank timeline is a total one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
The post page never passed onDelete, so PostCard called a callback
nobody had supplied. The delete itself succeeded — verified in
production: is_deleted true, deleted_at set — and the page went on
rendering the post until a reload replaced it with "This post doesn't
exist". Deleting a reply had the same gap.

The main post now navigates to the timeline, with replace rather than
push so Back cannot return to a page with nothing to show. A deleted
reply is removed from the thread at whatever depth it sat, and its
parent's reply count follows it down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
@github-actions
github-actions Bot merged commit 1cc373f into main Aug 28, 2026
6 checks passed
@github-actions
github-actions Bot deleted the fix/reaction-read branch August 28, 2026 12:18
github-actions Bot pushed a commit that referenced this pull request Aug 28, 2026
* perf(timeline): opening a thread cost a round-trip per reply

getReplies recursed: one query AND one enrichEventsForDisplay per node.
Enrichment is several requests — profiles, projects, the reader's id, the
three reaction tables — so a thread cost roughly six round-trips per reply.
I introduced the reader's-id and reaction queries in #811 without noticing
they sat inside a per-node recursion.

Measured in production by opening one three-reply post: eight /auth/v1/user
calls alone.

Two changes, both structural:

- getReplies fetches the tree a LEVEL at a time (one query per depth,
  regardless of width) and enriches the whole flat list ONCE, then assembles
  parent -> children from a map.
- getCurrentUserId caches the in-flight PROMISE, so concurrent callers
  collapse onto one /auth/v1/user instead of racing to make several. A
  failure is never cached - caching "nobody is signed in" would outlive the
  blip and render the timeline as signed-out.

The tests assert the COST, not just the tree. The recursive version built a
perfectly correct tree, which is exactly why nothing caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5

* test(timeline): make the reply-tree mock honour the parent filter

The first version of this mock returned rows by a level counter, ignoring
what was asked for. That made "one query per level" and "one query per node"
indistinguishable: a mutation swapping .in(parents) for .eq(parents[0])
stayed green, so the cost assertion was decorative.

The mock now filters on parent_event_id, and a sibling-branch case pins that
both branches are fetched in one query AND that neither loses its children.
Both mutations are red against it and green after restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5

---------

Co-authored-by: Georgy Butaev <41178744+g-but@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant