Skip to content

Bug: FeedView cannot see in-place row updates — merges only unseen ids, keyed on count #105

Closed

Description

@Adron

Found while implementing #96 (see PR #103).

The problem

FeedView mirrors store.feedMessages into a private @State private var messages and reconciles
in .onChange(of: store.feedMessages.count) (Views/FeedView.swift:385). The merge adds only ids it
does not already hold:

let existingIds = Set(messages.map { $0.id })
let newMessages = store.feedMessages.filter { !existingIds.contains($0.id) }

So any change to an existing row is invisible to the feed:

  • the trigger is .count, which does not move when a row is edited in place, and
  • even if it fired, the merge skips ids already present.

The store and the persisted cache are correct; only the view's private copy goes stale, until a full
reload replaces it.

How it surfaced

#103 adds AppDataStore.applyLinkMetadata(_:toMessageId:) to backfill a just-posted row's link
preview. The store updates correctly and the test proves it — and the feed still renders the old row.

Why it is worth fixing properly

This is not specific to link metadata. Any future in-place mutation — a dig count corrected from the
server, an edited body, a moderation state change — hits the same wall, and each one will look like
its own mysterious bug. The dig path already works around it with a separate digStates dictionary
and a locallyToggled set, which is the same problem solved once, locally.

Build this

  • Make the reconciliation respond to content changes, not just .count. Options, roughly in
    increasing order of cost: observe the array itself rather than its count (Message is
    Hashable); or replace matched ids in place during the merge rather than skipping them; or
    drop the private @State mirror and render from the store directly.
  • Preserve what the mirror is currently buying — scroll position and the syncedFromStore
    first-load behaviour must not regress, and a change must not re-trigger a load.
  • Consider whether digStates / locallyToggled can then collapse into the same path. Do not do
    it in the same PR unless it falls out cleanly.

Acceptance criteria

  • Mutating one message in AppDataStore updates that row in the rendered feed without a reload.
  • No scroll-position regression, no extra fetches, no duplicate rows.
  • A test that mutates a store message and asserts the view's data source reflects it.

Blocks

#103 / #96 are inert until this and #104 land.

Files: Views/FeedView.swift, possibly Services/AppDataStore.swift.

Activity

  1. added
    bugSomething isn't working
    ws:papercutsW8 — small parity papercuts
    on Sep 16, 2026
  2. Adron commented on Sep 16, 2026

    @Adron
    MemberAuthor

    Implemented in #107. Closing here — further work tracks in the PR.

    This issue's cheapest suggested option does not work, and the reason is a repo-wide trap

    I suggested "observe the array itself rather than its count (Message is Hashable)". That would
    silently never fire. Message's conformance is deliberately identity-based:

    extension Message: Hashable {
        static func == (lhs: Message, rhs: Message) -> Bool { lhs.id == rhs.id }
    

    So [Message] compares equal after a row's content is replaced, and
    .onChange(of: store.feedMessages) observes nothing. Worth knowing generally: any
    onChange(of:) over a Message collection anywhere in the app has this problem.

    Making == content-based would fix the trigger but changes a type used app-wide and needs
    Equatable on four nested payload types — and would impose a deep array diff on every body
    evaluation of the busiest screen. #107 uses an O(1) feedRevision counter instead.

    What landed

    Two halves, because the trigger and the merge were both broken:

    1. AppDataStore.feedRevision, a @Published counter bumped from didSet on feedMessages;
      FeedView observes that instead of .count.
    2. FeedMerge.merge(existing:incoming:) extracted to Services/ — matched ids replaced in place
      with position preserved, unseen ids still prepended, pagination rows the store never saw kept.

    "Render from the store" was considered and rejected: the private @State is a working copy, not a
    mirror — loadMore() appends pages the store never holds, and loadMessages() replaces it wholesale
    for the filtered views.

    A latent bug fixed on the way

    AppDataStore.updateFeedMessage(_:) was added because nothing on main could mutate an existing
    feed row. Its production call site in FeedView's edit callback also fixes real staleness: an edited
    post previously updated only the view's copy, leaving the store and its persisted cache holding
    the old text.

    Follow-up filed

    FeedView.deleteMessage has the same bug in the other direction — it removes from the working copy
    only, so the persisted cache keeps a deleted post. Filed as #108. Deliberately not fixed here to keep
    this PR to one behaviour.

    Collapsing digStates / locallyToggled into the merge was checked and does not fall out free —
    locallyToggled encodes a policy the merge doesn't know about — so it was left alone per scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingws:papercutsW8 — small parity papercuts

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions