Skip to content

Conversation

Soxasora
Copy link
Member

@Soxasora Soxasora commented Jul 29, 2025

Description

Closes #2358
Auto-shows new comments as they arrive, with a neat CSS fade-in animation, preserving the scroll position on injection by making use of MutationObserver to detect DOM mutations and requestAnimationFrame to update the scroll position before the next repaint.

Also introduces a slightly tweaked outlining system, that de-outlines the comment only if it actually has an outline/hasn't been already de-outlined.

Legacy live comments code has been deleted.

Screenshots

Auto-show animation

autoshow-base.mp4

Scroll position is preserved to avoid layout shifts

autoshow-preservescroll.mp4

Additional Context

Preserve scroll pipeline

  1. Record the scroll position (window.scrollY)
  2. Pick an anchor element (ref) at the center of the viewport
  const ref = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
  const refTop = ref ? ref.getBoundingClientRect().top + scrollTop : scrollTop
  1. MutationObserver watches the page for DOM changes

When a DOM mutation fires:

  1. Wait for the next animation frame, ensuring DOM full update
  2. Measure the anchor element new position
      // get the new position of the reference element along with the new scroll position
      const newRefTop = ref ? ref.getBoundingClientRect().top + window.scrollY : window.scrollY
      // has the reference element moved?
      const refMoved = newRefTop - refTop
  1. If refMoved > 0 then something was inserted above the anchor, pushing it downwards.
  2. To compensante, scroll down by the refMoved amount
      // if the reference element moved, we need to scroll to the new position
      if (refMoved > 0) {
        window.scrollTo({
          top: scrollTop + Math.ceil(refMoved),
          behavior: 'instant'
        })
      }

The MutationObserver is disconnected at the end of the scroll changes or after a 1 second fallback timeout in the case of no DOM mutations (although almost impossible)

List of changes

Live Comments:
+ 5 seconds polling
+ live comments are directly injected as they are received
  + auto-sort via server-side ASC query
  + depth via rootId <-> item.path relationship
+ preserve scroll position on live comment injection
  + `MutationObserver` to detect DOM changes
  + tracks a `ref` at viewport center to detect if a change is happening at the top, triggering scroll preservation
  + preserve scroll position only for true injections (dedupe OK)
+ injection will set commentsViewedAt to Date.now
+ read the query/fragment then inject, allowing manual flow control
- removed `ShowNewComments`
- removed live comments counts
- removed new comments dot from `ViewAllReplies`
- removed `newComments` client field
- removed update query/fragment cache functions

Comments:
+ live comments `fadeIn` animation
+ safer outlining (use rootLastCommentAt on commentsViewedAt absence), de-outline (only if it has an outline)
+ [fix] `rootLastCommentAt` will fallback to `parentCreatedAt`

Quirks

  • scroll is preserved regardless of whether we inject a comment or not after deduplication
  • safari animations can be choppy on macOS

Checklist

Are your changes backward compatible? Please answer below:

For example, a change is not backward compatible if you removed a GraphQL field or dropped a database column.
Yes

On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:
7

  • scroll preservation: OK
  • depth-based injection: OK
  • comments on different depths: OK
  • animation and cleanup: OK
  • comment deduplication: OK
  • comment edits: OK

For frontend changes: Tested on mobile, light and dark mode? Please answer below:
n/a

Did you introduce any new environment variables? If so, call them out explicitly here:
n/a

@Soxasora Soxasora changed the title live comments: Auto show new comments live comments: auto show new comments Jul 29, 2025
@Soxasora Soxasora added enhancement improvements to existing features ui/ux labels Jul 29, 2025
Soxasora and others added 11 commits August 2, 2025 16:54
…o comments have been injected

- don't preserve scroll if after deduplication we don't inject any comments

- use manual read/write cache updates to control the flow
-- allows to check if we are really injecting or not

- reduce polling to 5 seconds instead of 10

- light cleanup
-- removed update cache functions
-- added 'injected' to typeDefs (gql consistency)
Refactor:
+ clearer variables
+ depth calculation utility function
+ use destructured Apollo cache
+ extract item object from item query
+ skip ignored comment instead of ending the loop

CSS:
+ from-to fadeIn animation keyframes
- floatingComments unused class

Favicon:
+ provider exported by default
Comment on lines +112 to +114
// directly inject new comments into the cache, preserving scroll position
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
Copy link
Member Author

@Soxasora Soxasora Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll is preserved even for comments that ultimately gets deduplicated (e.g. your own comments on the same tab). It may be an inconvenience, not really expensive but it is something to think about.

The most straightforward solution is to:

  • Read the existing comments from cache, dedupe, and then maybe write the fragment

But I realized that writeFragment always returns the Item reference ID in the cache, even when it fails. This messes up things for comments that require different fragments because of the comments field absence.

Therefore a truly effective solution would be to:

  • Read, dedupe, call updateFragment which runs another read and ultimately a write.

updateFragment will explicitly fail if the provided object doesn't match the fragment, which enables us to retry with other fragments until it fits. But this would mean doing read -> read/write

In addition, since dedupe happens by comparing the new comment with the parent's existing comments, both solutions would preserve scroll for each comment in the newComments array, spawning several MutationObserver and requestAnimationFrame


At the actual state, scroll is instead preserved for batches of new comments, but preserveScroll will run even if no comments are being effectively injected.

@Soxasora Soxasora marked this pull request as ready for review August 8, 2025 10:16
Comment on lines +117 to +126
const unsetOutline = () => {
if (!ref.current) return
const hasOutline = ref.current.classList.contains('outline-new-comment') || ref.current.classList.contains('outline-new-injected-comment')
const hasOutlineUnset = ref.current.classList.contains('outline-new-comment-unset')

// don't try to unset the outline if the comment is not outlined or we already unset the outline
if (hasOutline && !hasOutlineUnset) {
ref.current.classList.add('outline-new-comment-unset')
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outlining is addressed by #2401 with a separate hook, as it's becoming too big to stay here

Comment on lines +73 to 83
export function calculateDepth (path, rootId, parentId) {
// calculate depth by counting path segments from root to parent
const pathSegments = path.split('.')
const rootIndex = pathSegments.indexOf(rootId.toString())
const parentIndex = pathSegments.indexOf(parentId.toString())

// depth is the distance from root to parent in the path
const depth = parentIndex - rootIndex

return depth
}
Copy link
Member Author

@Soxasora Soxasora Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in place to avoid injecting comments that are not visible by the user.
We did this to show accurate counts and avoid misleading the user, but since we automatically inject now this is not required anymore.

Removing depth protection will update comments that are not visible but present in cache, useful to show that new comments are present down the tree, especially with #2401 that will outline the MoreReplies button.
But, there might be comments that are not present in cache, like when we fully reload an Item and it loads them until the depth limit has been reached. In this case new comments to these absent comments are dropped.

This comment was marked as off-topic.

@huumn huumn merged commit 0e842e9 into stackernews:master Aug 8, 2025
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement improvements to existing features ui/ux
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Auto-show new comments as they arrive
2 participants