Skip to content

New question feed updates#4526

Open
cemreinanc wants to merge 18 commits intomainfrom
feat/new-question-feed
Open

New question feed updates#4526
cemreinanc wants to merge 18 commits intomainfrom
feat/new-question-feed

Conversation

@cemreinanc
Copy link
Copy Markdown
Contributor

@cemreinanc cemreinanc commented Mar 23, 2026

Notes:

  • sometimes masonry calculations doesn't work properly as tiles change height lazily after rendering. Instead of more complicating the logic, tiles should not change height after initial rendering as a best practice anyways.

Summary by CodeRabbit

  • New Features

    • Layout switcher to toggle and persist list/grid feeds
    • Sticky filter bar on the Questions page
  • Improvements

    • New responsive masonry/grid feed for improved post layouts
    • Refined sidebar, card, and filter responsiveness; better scroll/overflow handling
    • Collapsible search input; removed extra random button
  • Localization

    • Added translations for "Sort", "translated by", "List layout", and "Grid layout" across locales

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds feed layout (list/grid) support with a provider and UI, introduces a responsive Masonry renderer and caching, a StickyFilterBar, enforces list layout on several feeds, refactors filter/sidebar responsiveness, adjusts modal/popover scroll locking, increases banner z-index, and adds i18n keys (sort, translatedBy, listLayout, gridLayout) across locales.

Changes

Cohort / File(s) Summary
Localization - New keys
front_end/messages/cs.json, front_end/messages/en.json, front_end/messages/es.json, front_end/messages/pt.json, front_end/messages/zh-TW.json, front_end/messages/zh.json
Added sort, translatedBy, listLayout, and gridLayout translation entries.
Feed layout context & constants
front_end/src/contexts/feed_layout_context.tsx, front_end/src/constants/posts_feed.ts, front_end/src/app/layout.tsx
New FeedLayout context/provider plus FEED_LAYOUT_COOKIE; RootLayout reads cookie and wraps app in FeedLayoutProvider.
Layout switcher UI
front_end/src/components/ui/layout_switcher.tsx
New LayoutSwitcher component and exported FeedLayout type (`"list"
Masonry renderer
front_end/src/components/ui/masonry.tsx
Added responsive Masonry with balanced layout, measurement and assignment logic, utility exports and hooks.
Feed plumbing & rendering
front_end/src/components/posts_feed/build_feed_items.ts, front_end/src/components/posts_feed/index.tsx, front_end/src/components/posts_feed/paginated_feed.tsx
WeakMap caching for FeedItems; new optional forceLayout prop propagated; feed rendering moved to FeedLayoutView/FeedItemCard and uses Masonry.
Pages enforcing list layout
front_end/src/app/(main)/questions/page.tsx, front_end/src/app/(main)/tournaments/.../tournament_feed.tsx, front_end/src/app/(main)/accounts/profile/[id]/questions/page.tsx, front_end/src/app/(main)/c/[slug]/page.tsx, front_end/src/app/(main)/c/[slug]/settings/page.tsx, front_end/src/app/(main)/news/page.tsx
Propagated forceLayout="list" to multiple feed instances; Questions page restructured to use StickyFilterBar.
Sticky filter bar
front_end/src/app/(main)/questions/components/sticky_filter_bar.tsx
New client component using IntersectionObserver + ResizeObserver to toggle sticky state and glass styling; exported default component.
Filters & responsive logic
front_end/src/app/(main)/questions/components/feed_filters/main.tsx, .../my_predictions.tsx, .../my_questions_and_posts.tsx, front_end/src/components/posts_filters.tsx, front_end/src/components/tournament_filters.tsx
Refactored sort option visibility to depend on breakpoints (useBreakpoint), removed user-presence gating, updated memo deps; PostsFilters gained className and forceLayout.
Sidebar measurements & positioning
front_end/src/app/(main)/questions/components/sidebar.tsx
Added ResizeObserver to measure sidebar outer height and set --feed-sidebar-mobile-height; replaced banner-dependent top-class logic and adjusted responsive classes.
Modal / Popover overflow & popover UI
front_end/src/components/base_modal.tsx, front_end/src/components/popover_filter/index.tsx, front_end/src/app/globals.css
Changed scroll-lock from hiddenclip for modal/popover; PopoverFilter gained hasActiveFilters and a clear button; globals.css overflow selectors broadened.
Banner, search, small UI updates
front_end/src/app/(main)/components/content_translated_banner/index.tsx, front_end/src/app/(main)/components/global_search.tsx, front_end/src/components/communities_feed/paginated_communities_feed.tsx, front_end/src/components/random_button.tsx, front_end/src/components/search_input.tsx, front_end/src/components/ui/listbox.tsx, front_end/src/components/ui/tailwind-indicator.tsx
Banner z-index increased and translatedBy localized; removed RandomButton from GlobalSearch; centered communities feed container; refactored RandomButton; added collapsible to SearchInput; listbox menu alignment configurable; added dev-only TailwindIndicator.
Posts filters UI changes
front_end/src/components/posts_filters.tsx
Replaced ButtonGroup with individual Buttons, conditional LayoutSwitcher, removed VisibilityObserver usage, added className and forceLayout props and adjusted analytics payloads.
Post card responsive refactors
front_end/src/components/post_card/... (multiple files)
Switched many viewport breakpoints to container-query-based selectors at specific widths (e.g., @[480px], @[550px], @[680px]) across multiple post-card components.
Performance / caching helper
front_end/src/components/posts_feed/build_feed_items.ts
Added WeakMap caches and helper getters to reuse created FeedItem objects.
Tailwind indicator
front_end/src/components/ui/tailwind-indicator.tsx
Added dev-only visual breakpoint indicator component.
Misc small layout tweaks
front_end/src/components/communities_feed/paginated_communities_feed.tsx, front_end/src/components/random_button.tsx, front_end/src/components/search_input.tsx, front_end/src/components/ui/listbox.tsx
Various small refactors: centering container, component prop typing, collapsible search, configurable menu alignment.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Root as RootLayout
    participant FLP as FeedLayoutProvider
    participant Page as FeedPage
    participant Feed as PaginatedPostsFeed/AwaitedPostsFeed
    participant LayoutView as FeedLayoutView
    participant Masonry as Masonry
    participant Card as FeedItemCard

    User->>Root: request feed page
    Root->>Root: read FEED_LAYOUT_COOKIE or default
    Root->>FLP: initialize with initialLayout
    FLP->>Page: provide layout context
    Page->>Feed: render feed (may pass forceLayout)
    Feed->>Feed: resolve layout = forceLayout ?? contextLayout
    Feed->>LayoutView: pass items & layout
    LayoutView->>Masonry: configure columns/gap & balanced mode
    Masonry->>Card: render each item
    Card->>User: display post/project
    User->>FLP: click LayoutSwitcher -> setLayout("grid")
    FLP->>Feed: context updated -> re-render
Loading
sequenceDiagram
    participant User
    participant QPage as QuestionsPage
    participant SFB as StickyFilterBar
    participant IO as IntersectionObserver
    participant Filters as FeedFilters
    participant Feed as AwaitedPostsFeed

    User->>QPage: open Questions page
    QPage->>SFB: render StickyFilterBar(FeedFilters)
    SFB->>IO: attach observer to sentinel
    User->>QPage: scroll
    IO->>SFB: update isStuck state
    SFB->>SFB: toggle glass styling/top offset
    SFB->>Filters: render filters (respect forceLayout)
    Filters->>Feed: apply filters & render feed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • elisescu
  • hlbmtc
  • ncarazon

Poem

🐇 I hopped through layouts, list and grid in tow,

I stacked the tiles where Masonry should go.
A sticky little filter keeps the view just right,
Translated labels gleam in morning light.
Cheers — the feed now hops with delight! 🎉

🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The PR title 'New question feed updates' is generic and vague, using non-descriptive phrasing that doesn't convey the specific technical changes (masonry layout, sticky filter bar, responsive feed layout forcing, etc.) made in the changeset. Consider a more specific title that highlights the main technical change, such as 'Implement masonry layout and sticky filter bar for question feed' or 'Refactor question feed with forced list layout and UI updates'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/new-question-feed

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 23, 2026

🚀 Preview Environment

Your preview environment is ready!

Resource Details
🌐 Preview URL https://metaculus-pr-4526-feat-new-question-feed-preview.mtcl.cc
📦 Docker Image ghcr.io/metaculus/metaculus:feat-new-question-feed-964794f
🗄️ PostgreSQL NeonDB branch preview/pr-4526-feat-new-question-feed
Redis Fly Redis mtc-redis-pr-4526-feat-new-question-feed

Details

  • Commit: 9f3328d342acb2791f64c61cf1b35bb47d762007
  • Branch: feat/new-question-feed
  • Fly App: metaculus-pr-4526-feat-new-question-feed

ℹ️ Preview Environment Info

Isolation:

  • PostgreSQL and Redis are fully isolated from production
  • Each PR gets its own database branch and Redis instance
  • Changes pushed to this PR will trigger a new deployment

Limitations:

  • Background workers and cron jobs are not deployed in preview environments
  • If you need to test background jobs, use Heroku staging environments

Cleanup:

  • This preview will be automatically destroyed when the PR is closed

@cemreinanc cemreinanc changed the title New question feed updates New question feed updates (part 1) Mar 25, 2026
@cemreinanc cemreinanc marked this pull request as ready for review March 25, 2026 12:40
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
front_end/src/app/(main)/components/content_translated_banner/index.tsx (1)

35-53: ⚠️ Potential issue | 🟡 Minor

Localize the attribution label.

This banner block still hardcodes translated by, so every locale gets English copy even though the rest of the component already uses useTranslations(). Please move that label into the message catalog as well.

Based on learnings: Do not hardcode English strings in TSX components; prefer useTranslations() and i18n strings for UI text across components.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/app/`(main)/components/content_translated_banner/index.tsx
around lines 35 - 53, The banner currently hardcodes the English label
"translated by"; replace it with a localized string from the same translation
hook used earlier (e.g., call t("translatedBy") or similar) in the component
(index.tsx) and update the message catalog keys accordingly; ensure the new key
exists in your locale files and use the same useTranslations() instance already
imported/used in this component so the span renders t("translatedBy") instead of
the hardcoded text.
front_end/src/components/base_modal.tsx (1)

34-43: ⚠️ Potential issue | 🟠 Major

Global scroll lock is still race-prone across overlays.

Line 36 and Line 42 mutate document.body.style.overflow without coordination. With front_end/src/components/popover_filter/index.tsx (Line 33-40) doing the same, closing one overlay can restore scrolling while another overlay is still open.

🔧 Proposed fix (reference-counted body scroll lock)
+const BODY_SCROLL_LOCK_COUNT_KEY = "overlayScrollLockCount";
+
+const lockBodyScroll = () => {
+  const current = Number(document.body.dataset[BODY_SCROLL_LOCK_COUNT_KEY] ?? "0");
+  const next = current + 1;
+  document.body.dataset[BODY_SCROLL_LOCK_COUNT_KEY] = String(next);
+  document.body.style.overflow = "clip";
+};
+
+const unlockBodyScroll = () => {
+  const current = Number(document.body.dataset[BODY_SCROLL_LOCK_COUNT_KEY] ?? "0");
+  const next = Math.max(0, current - 1);
+  if (next === 0) {
+    delete document.body.dataset[BODY_SCROLL_LOCK_COUNT_KEY];
+    document.body.style.overflow = "unset";
+  } else {
+    document.body.dataset[BODY_SCROLL_LOCK_COUNT_KEY] = String(next);
+  }
+};
+
   useEffect(() => {
-    if (isOpen && isImmersive) {
-      document.body.style.overflow = "clip";
-    } else {
-      document.body.style.overflow = "unset";
-    }
-
-    return () => {
-      document.body.style.overflow = "unset";
-    };
+    if (!(isOpen && isImmersive)) return;
+    lockBodyScroll();
+    return () => unlockBodyScroll();
   }, [isOpen, isImmersive]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/components/base_modal.tsx` around lines 34 - 43, The effect in
base_modal.tsx that directly sets document.body.style.overflow is race-prone
with other overlays (e.g., popover_filter) — replace the direct mutations with a
shared reference-counted scroll-lock API (either a small module exposing
acquireBodyScrollLock/releaseBodyScrollLock or a hook like useBodyScrollLock)
and call that from the useEffect in base_modal (and update popover_filter to use
the same API); ensure acquire increments a counter and sets overflow="clip" when
counter goes from 0→1, and release decrements and only restores overflow when
counter reaches 0, and call release in the effect cleanup.
front_end/src/components/ui/listbox.tsx (1)

159-165: ⚠️ Potential issue | 🟠 Major

menuPosition still does not affect portal placement.

FloatingMenu now receives the prop, but the fixed-position style still always anchors to left: rect.left. In portal mode, a menu wider than its button will therefore expand from the left edge even when the caller wants right alignment, which can push options off-screen near the viewport edge.

Proposed fix
      style={
        !renderInPortal
          ? undefined
          : {
              position: "fixed",
-             left: rect?.left,
+             left: menuPosition === "left" ? rect?.left : undefined,
+             right:
+               menuPosition === "right" && rect
+                 ? window.innerWidth - rect.right
+                 : undefined,
              top: !flipUp ? rect?.bottom : undefined,
              bottom: flipUp
                ? window.innerHeight - (rect?.top ?? 0)
                : undefined,

Also applies to: 191-205

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/components/ui/listbox.tsx` around lines 159 - 165, FloatingMenu
receives the menuPosition prop but portal placement still anchors using
rect.left; update the portal positioning logic inside FloatingMenu (the branch
that uses renderInPortal) to honor menuPosition ('left'|'right'|'center' etc.)
by computing the horizontal offset from rect (e.g., use rect.right - menuWidth
for 'right', rect.left for 'left', or center accordingly) or set the appropriate
CSS right/left based on viewport bounds before applying fixed positioning;
ensure the same change is applied to the other portal code path that mirrors
this logic (the second FloatingMenu usage handling
optionsClassName/menuMinWidthMatchesButton) so portal-mode menus align as
requested.
🧹 Nitpick comments (2)
front_end/src/components/random_button.tsx (1)

38-38: Hardcoded aria-label should use i18n.

The string "Random Question" is hardcoded in English. Per the project's i18n conventions, use useTranslations() instead to ensure this label is localized.

♻️ Suggested fix
+import { useTranslations } from "next-intl";
+
 const RandomButton: FC<ComponentProps<typeof Button>> = ({
   className,
   ...props
 }) => {
+  const t = useTranslations();
   const [isLoading, setIsLoading] = useState(false);
   const router = useRouter();
   ...
   return (
     <Button
       onClick={handleRandomClick}
       disabled={isLoading}
-      aria-label="Random Question"
+      aria-label={t("randomQuestion")}
       size="md"

Then add the "randomQuestion" key to your translation files.

Based on learnings: "Do not hardcode English strings in TSX components... Prefer using useTranslations() and i18n strings for UI text across components."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/components/random_button.tsx` at line 38, The aria-label on the
RandomButton component is hardcoded as "Random Question"; replace it by calling
useTranslations() in front_end/src/components/random_button.tsx (e.g., const t =
useTranslations(...)) and use t("randomQuestion") for the aria-label and any
visible text, and then add a "randomQuestion" key to your translation JSONs so
the label is localized across locales.
front_end/src/app/globals.css (1)

14-16: Scope this override narrower than any inline overflow on <html>.

html[style*="overflow"] matches every inline overflow mutation on the document root, so any legitimate document.documentElement.style.overflow = ... usage will now be silently undone by the base layer. Tightening this to the specific scroll-lock condition avoids another class of overlay regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/app/globals.css` around lines 14 - 16, The global CSS rule is
too broad: replace the selector html[style*="overflow"] with a narrower check
that only matches the scroll-lock case (e.g. html[style*="overflow: hidden"] or
the exact inline style string your overlay library sets) so you only override
the document root when the page is actually scroll-locked; update the combined
selector (html:has(`#headlessui-portal-root`), html[style*="overflow"]) to use
html:has(`#headlessui-portal-root`) and html[style*="overflow: hidden"] (or the
exact inline style used) so legitimate programmatic mutations to
document.documentElement.style.overflow are not globally undone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@front_end/src/app/`(main)/questions/components/feed_filters/main.tsx:
- Around line 126-128: The memoized value mainSortNewsVisible is missing
PUBLIC_MINIMAL_UI in its useMemo dependency array, causing stale values when
PUBLIC_MINIMAL_UI changes; update the useMemo that computes mainSortNewsVisible
to include PUBLIC_MINIMAL_UI alongside t, isLargeScreen, and isMediumScreen so
the memo recomputes when PUBLIC_MINIMAL_UI updates.

In `@front_end/src/app/layout.tsx`:
- Around line 98-99: The current assignment to feedLayout casts the cookie value
directly to FeedLayout which trusts client-controlled FEED_LAYOUT_COOKIE; change
it to validate the cookie value returned by
cookieStore.get(FEED_LAYOUT_COOKIE)?.value and only accept it if it strictly
equals "list" or "grid", otherwise use FEED_LAYOUT_DEFAULT so FeedLayoutProvider
receives a safe initialLayout; update the logic around feedLayout (and any code
relying on that value) to remove the unchecked cast and perform this whitelist
check before passing the value into FeedLayoutProvider.

In `@front_end/src/components/search_input.tsx`:
- Around line 59-60: isExpanded is only initialized from value and can get out
of sync when the parent changes value; add a useEffect that watches value (and
collapsible) and calls setIsExpanded(!!value) so the input expands whenever a
non-empty value is provided externally. Locate the isExpanded state and updater
(isExpanded, setIsExpanded) and add the effect to synchronize them, ensuring the
effect no-ops when collapsible is false to preserve current behavior; also apply
the same synchronization in the other related block that manages expansion (the
code referenced around the 86-108 region).
- Around line 88-91: The collapse icon button currently uses
aria-label={placeholder} which can be empty; change the component API to require
a localized label prop (e.g., collapseButtonLabel or ariaLabel) when rendering
in collapsible mode and use that prop for the button aria-label instead of
placeholder; update the SearchInput component (and any caller sites) to pass an
i18n-backed string, validate that collapseButtonLabel is present when
collapsible/expand mode is enabled (throw or assert), and remove any hardcoded
English fallback so the button always has an accessible name.
- Around line 62-71: The expand() callback currently only handles object refs
and thus fails to focus when a callback ref is passed via prop inputRef; update
expand (and the ref usage) to handle both object refs and callback refs by: if
inputRef is a function, call it with null/element as needed or store the element
temporarily and call inputRef(element) so you can focus that element, otherwise
use inputRef.current or internalRef.current; ensure the ref passed on the input
uses the same logic so internalRef is set when inputRef is a callback. Also add
a fallback aria-label for the collapsed icon button (use placeholder || "Open
search" or a similar descriptive default) so the button is accessible when
placeholder is undefined. Reference expand, inputRef, internalRef, and the
collapsed icon button/ref prop when making changes.

In `@front_end/src/components/ui/layout_switcher.tsx`:
- Around line 25-50: Replace the hardcoded aria-label strings in the
LayoutSwitcher component with localized strings from useTranslations(): import
and call useTranslations() at the top of the component (e.g., const t =
useTranslations()), then change aria-label="List layout" to
aria-label={t('layout.list')} and aria-label="Grid layout" to
aria-label={t('layout.grid')} (or use the appropriate i18n keys your project
uses); keep the existing onChange and value logic intact so the buttons still
call onChange("list") / onChange("grid").

In `@front_end/src/components/ui/masonry.tsx`:
- Around line 74-84: The Masonry component currently uses indexes/object
identity for keys and in its internal Map bookkeeping which breaks stability
when items are recreated or redistributed; add a getKey: (item: T, idx: number)
=> string|number to MasonryProps and use getKey(item, idx) everywhere instead of
idx or item identity—use that value for the React key on rendered items (in
render wrapper) and as the Map key in placement tracking and any maps that track
item -> column/position (update any usages inside functions that compute
balanced placement and column assignment); ensure getKey is passed through to
the places where columns are built and where items are iterated so the same
stable key is used both for React keys and all internal maps.
- Around line 321-329: The createDataColumns function incorrectly treats falsy
values as missing by using a truthy check on item; update the condition in the
inner loop to explicitly check for undefined (e.g., item !== undefined) so
values like 0, false, and "" are preserved; locate the check that references
item within createDataColumns (and the variables result, data, columns) and
replace the truthy guard with an explicit undefined comparison.
- Around line 21-31: The initial state in useMediaValues is set to columns: 0
which causes Masonry to return null on first render; change the useState
initializer in useMediaValues to derive its default from the base layout values
(e.g. setValues initial object to { columns: columns?.[0] ?? 0, gap: gap?.[0] ??
0 }) so the first render uses the base columns/gap instead of 0, and apply the
same change to the other useState occurrence for this hook (the second
initializer around the 95-99 area) so both initial states mirror the provided
columns[0]/gap[0].

---

Outside diff comments:
In `@front_end/src/app/`(main)/components/content_translated_banner/index.tsx:
- Around line 35-53: The banner currently hardcodes the English label
"translated by"; replace it with a localized string from the same translation
hook used earlier (e.g., call t("translatedBy") or similar) in the component
(index.tsx) and update the message catalog keys accordingly; ensure the new key
exists in your locale files and use the same useTranslations() instance already
imported/used in this component so the span renders t("translatedBy") instead of
the hardcoded text.

In `@front_end/src/components/base_modal.tsx`:
- Around line 34-43: The effect in base_modal.tsx that directly sets
document.body.style.overflow is race-prone with other overlays (e.g.,
popover_filter) — replace the direct mutations with a shared reference-counted
scroll-lock API (either a small module exposing
acquireBodyScrollLock/releaseBodyScrollLock or a hook like useBodyScrollLock)
and call that from the useEffect in base_modal (and update popover_filter to use
the same API); ensure acquire increments a counter and sets overflow="clip" when
counter goes from 0→1, and release decrements and only restores overflow when
counter reaches 0, and call release in the effect cleanup.

In `@front_end/src/components/ui/listbox.tsx`:
- Around line 159-165: FloatingMenu receives the menuPosition prop but portal
placement still anchors using rect.left; update the portal positioning logic
inside FloatingMenu (the branch that uses renderInPortal) to honor menuPosition
('left'|'right'|'center' etc.) by computing the horizontal offset from rect
(e.g., use rect.right - menuWidth for 'right', rect.left for 'left', or center
accordingly) or set the appropriate CSS right/left based on viewport bounds
before applying fixed positioning; ensure the same change is applied to the
other portal code path that mirrors this logic (the second FloatingMenu usage
handling optionsClassName/menuMinWidthMatchesButton) so portal-mode menus align
as requested.

---

Nitpick comments:
In `@front_end/src/app/globals.css`:
- Around line 14-16: The global CSS rule is too broad: replace the selector
html[style*="overflow"] with a narrower check that only matches the scroll-lock
case (e.g. html[style*="overflow: hidden"] or the exact inline style string your
overlay library sets) so you only override the document root when the page is
actually scroll-locked; update the combined selector
(html:has(`#headlessui-portal-root`), html[style*="overflow"]) to use
html:has(`#headlessui-portal-root`) and html[style*="overflow: hidden"] (or the
exact inline style used) so legitimate programmatic mutations to
document.documentElement.style.overflow are not globally undone.

In `@front_end/src/components/random_button.tsx`:
- Line 38: The aria-label on the RandomButton component is hardcoded as "Random
Question"; replace it by calling useTranslations() in
front_end/src/components/random_button.tsx (e.g., const t =
useTranslations(...)) and use t("randomQuestion") for the aria-label and any
visible text, and then add a "randomQuestion" key to your translation JSONs so
the label is localized across locales.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1d5673d5-1c76-40de-93a3-a6755c8fc49e

📥 Commits

Reviewing files that changed from the base of the PR and between 3979e7a and 8225f63.

📒 Files selected for processing (37)
  • front_end/messages/cs.json
  • front_end/messages/en.json
  • front_end/messages/es.json
  • front_end/messages/pt.json
  • front_end/messages/zh-TW.json
  • front_end/messages/zh.json
  • front_end/src/app/(main)/(tournaments)/tournament/components/tournament_feed.tsx
  • front_end/src/app/(main)/accounts/profile/[id]/questions/page.tsx
  • front_end/src/app/(main)/c/[slug]/page.tsx
  • front_end/src/app/(main)/c/[slug]/settings/page.tsx
  • front_end/src/app/(main)/components/content_translated_banner/index.tsx
  • front_end/src/app/(main)/components/global_search.tsx
  • front_end/src/app/(main)/news/page.tsx
  • front_end/src/app/(main)/questions/components/feed_filters/main.tsx
  • front_end/src/app/(main)/questions/components/feed_filters/my_predictions.tsx
  • front_end/src/app/(main)/questions/components/feed_filters/my_questions_and_posts.tsx
  • front_end/src/app/(main)/questions/components/sidebar.tsx
  • front_end/src/app/(main)/questions/components/sticky_filter_bar.tsx
  • front_end/src/app/(main)/questions/page.tsx
  • front_end/src/app/globals.css
  • front_end/src/app/layout.tsx
  • front_end/src/components/base_modal.tsx
  • front_end/src/components/communities_feed/paginated_communities_feed.tsx
  • front_end/src/components/popover_filter/index.tsx
  • front_end/src/components/posts_feed/build_feed_items.ts
  • front_end/src/components/posts_feed/index.tsx
  • front_end/src/components/posts_feed/paginated_feed.tsx
  • front_end/src/components/posts_filters.tsx
  • front_end/src/components/random_button.tsx
  • front_end/src/components/search_input.tsx
  • front_end/src/components/tournament_filters.tsx
  • front_end/src/components/ui/layout_switcher.tsx
  • front_end/src/components/ui/listbox.tsx
  • front_end/src/components/ui/masonry.tsx
  • front_end/src/components/ui/tailwind-indicator.tsx
  • front_end/src/constants/posts_feed.ts
  • front_end/src/contexts/feed_layout_context.tsx
💤 Files with no reviewable changes (1)
  • front_end/src/app/(main)/components/global_search.tsx

@cemreinanc cemreinanc changed the title New question feed updates (part 1) New question feed updates Mar 28, 2026
Comment on lines +151 to +182
if (columns !== prevColumnCount.current) {
columnAssignments.current.clear();
measuredHeights.current.clear();
prevColumnCount.current = columns;
}

// Stale cleanup: remove items no longer in the array
const itemSet = new Set(items);
for (const key of columnAssignments.current.keys()) {
if (!itemSet.has(key)) {
columnAssignments.current.delete(key);
measuredHeights.current.delete(key);
}
}
for (const key of measuredHeights.current.keys()) {
if (!itemSet.has(key)) {
measuredHeights.current.delete(key);
}
}

const placedItems: T[] = [];
const pendingItems: T[] = [];
for (const item of items) {
if (columnAssignments.current.has(item)) {
placedItems.push(item);
} else {
pendingItems.push(item);
}
}

// Build columns for placed items
const placedColumns: T[][] = Array.from({ length: columns }, () => []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

BalancedMasonry performs side effects during render (clearing refs, iterating Maps). Anti-pattern for React strict mode and concurrent features. Move to useEffect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Those render-time mutations only actually do anything in two specific cases:

  1. The column count changed (breakpoint shift), it clears the maps so items get redistributed.
  2. Items were removed from the array, it deletes stale keys so the maps don't leak.

Outside of those two cases, every single one of those loops and conditionals is a no-op. The if conditional doesn't fire. The for loops iterate the maps and delete nothing because every key is still in itemSet. The code runs, does zero mutations, and falls through.

And even when they do fire, Strict Mode's double-render would just clear-and-rebuild identically both times; the result is the same and idempotent.

Moving this to useEffect would be strictly worse: you'd render one frame with stale data (wrong column count, dead items still assigned), paint that to the DOM, then fix the state or refs and re-render. It will cause a visible flicker for no reason. The whole point is that the refs need to be correct before the render output is computed, not after (like useEffect).

I couldn't find any other solution that fits these constraints and even if we mutate the ref contents still its usage seemed pretty careful. Any suggestions after considering these?

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
front_end/src/app/globals.css (1)

14-22: ⚠️ Potential issue | 🟠 Major

Scope these overflow overrides to HeadlessUI-managed state only.

Line 14 and Line 19 still match any inline root overflow styles, so unrelated modal/dialog flows can be overridden by overflow: clip/unset !important, causing scroll-lock leaks.

#!/bin/bash
# Verify whether non-HeadlessUI code also mutates root overflow/padding-right,
# which would be affected by the global selectors in globals.css.

rg -n -C3 --type=ts --type=tsx --type=js --type=jsx \
'document\.(documentElement|body)\.style\.(overflow|paddingRight)|setProperty\(\s*["'"'"'`](overflow|padding-right)' \
front_end/src

# Optional: quick scan for custom modal/dialog implementations that may rely on their own scroll-lock behavior.
rg -n -C2 --type=ts --type=tsx --type=js --type=jsx \
'\b(Modal|Dialog|Drawer|Popover)\b' \
front_end/src
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/app/globals.css` around lines 14 - 22, The global overflow
overrides currently target any inline root overflow via selectors
html[style*="overflow: hidden"], html[style*="overflow:hidden"],
body[style*="overflow: auto"], body[style*="overflow:auto"], which can break
non-HeadlessUI flows; restrict those selectors to only apply when
HeadlessUI-managed state is active by adding a dedicated scoping attribute/class
(e.g. html[data-headlessui-dialog-open], html[data-headlessui-modal-open] or
.headlessui-modal-open) so the rules only run for HeadlessUI modals/dialogs;
update the CSS selectors in globals.css to include that attribute/class and
ensure the HeadlessUI wrapper that controls opening/closing sets and removes
that attribute/class when Dialog/Modal opens/closes.
🧹 Nitpick comments (2)
front_end/src/components/ui/masonry.tsx (2)

152-171: Side effects during render are anti-pattern for React concurrent mode.

Lines 152-171 mutate columnAssignments.current and measuredHeights.current directly during render. In React's concurrent mode and strict mode, render functions may be called multiple times before committing, which could lead to inconsistent state if these mutations are re-executed.

Consider moving these cleanup operations to a useEffect or useLayoutEffect that runs when columns or items change.

♻️ Suggested approach
+ useEffect(() => {
+   // Breakpoint change: clear everything for full redistribution
+   columnAssignments.current.clear();
+   measuredHeights.current.clear();
+ }, [columns]);
+
+ useEffect(() => {
+   // Stale cleanup: remove items no longer in the array
+   const itemSet = new Set(items);
+   for (const key of columnAssignments.current.keys()) {
+     if (!itemSet.has(key)) {
+       columnAssignments.current.delete(key);
+       measuredHeights.current.delete(key);
+     }
+   }
+   for (const key of measuredHeights.current.keys()) {
+     if (!itemSet.has(key)) {
+       measuredHeights.current.delete(key);
+     }
+   }
+ }, [items]);

- // Breakpoint change: clear everything for full redistribution
- if (columns !== prevColumnCount.current) {
-   columnAssignments.current.clear();
-   measuredHeights.current.clear();
-   prevColumnCount.current = columns;
- }
-
- // Stale cleanup: remove items no longer in the array
- const itemSet = new Set(items);
- for (const key of columnAssignments.current.keys()) {
-   ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/components/ui/masonry.tsx` around lines 152 - 171, The render
is mutating refs (columnAssignments.current, measuredHeights.current,
prevColumnCount.current) which is unsafe in concurrent renders; move the
breakpoint-reset and stale-item cleanup into a hook (useEffect or
useLayoutEffect) that runs when columns or items change; specifically, remove
the in-render blocks that clear or delete from columnAssignments.current and
measuredHeights.current and implement a useLayoutEffect/useEffect whose
dependency array includes [columns, items] that performs: if columns !==
prevColumnCount.current then clear both refs and set prevColumnCount.current =
columns, and also iterate current keys to delete any entries not present in the
new items Set.

236-237: Consider documenting the suppressed lint rule.

The eslint-disable comment suppresses exhaustive-deps but doesn't explain the rationale. pendingItems is derived from items during render, so including pendingItems.length rather than pendingItems is intentional to avoid reference instability. A brief comment would help future maintainers understand this choice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/src/components/ui/masonry.tsx` around lines 236 - 237, Add a brief
explanatory comment above the eslint-disable-next-line
react-hooks/exhaustive-deps that states why the rule is suppressed: explain that
pendingItems is derived from items during render and its object identity can
change each render, so the effect intentionally depends on items, columns, and
pendingItems.length (not pendingItems) to avoid unnecessary re-runs; keep the
eslint-disable-next-line line but document the rationale referencing the
useEffect dependency array and the variables items, columns, and
pendingItems.length so future maintainers understand the choice.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@front_end/src/app/globals.css`:
- Around line 14-22: The global overflow overrides currently target any inline
root overflow via selectors html[style*="overflow: hidden"],
html[style*="overflow:hidden"], body[style*="overflow: auto"],
body[style*="overflow:auto"], which can break non-HeadlessUI flows; restrict
those selectors to only apply when HeadlessUI-managed state is active by adding
a dedicated scoping attribute/class (e.g. html[data-headlessui-dialog-open],
html[data-headlessui-modal-open] or .headlessui-modal-open) so the rules only
run for HeadlessUI modals/dialogs; update the CSS selectors in globals.css to
include that attribute/class and ensure the HeadlessUI wrapper that controls
opening/closing sets and removes that attribute/class when Dialog/Modal
opens/closes.

---

Nitpick comments:
In `@front_end/src/components/ui/masonry.tsx`:
- Around line 152-171: The render is mutating refs (columnAssignments.current,
measuredHeights.current, prevColumnCount.current) which is unsafe in concurrent
renders; move the breakpoint-reset and stale-item cleanup into a hook (useEffect
or useLayoutEffect) that runs when columns or items change; specifically, remove
the in-render blocks that clear or delete from columnAssignments.current and
measuredHeights.current and implement a useLayoutEffect/useEffect whose
dependency array includes [columns, items] that performs: if columns !==
prevColumnCount.current then clear both refs and set prevColumnCount.current =
columns, and also iterate current keys to delete any entries not present in the
new items Set.
- Around line 236-237: Add a brief explanatory comment above the
eslint-disable-next-line react-hooks/exhaustive-deps that states why the rule is
suppressed: explain that pendingItems is derived from items during render and
its object identity can change each render, so the effect intentionally depends
on items, columns, and pendingItems.length (not pendingItems) to avoid
unnecessary re-runs; keep the eslint-disable-next-line line but document the
rationale referencing the useEffect dependency array and the variables items,
columns, and pendingItems.length so future maintainers understand the choice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 74a0cadf-221d-48ab-822f-9ba9d3d1beb5

📥 Commits

Reviewing files that changed from the base of the PR and between 0bc7fc0 and 6746249.

📒 Files selected for processing (8)
  • front_end/src/app/(main)/components/global_search.tsx
  • front_end/src/app/globals.css
  • front_end/src/app/layout.tsx
  • front_end/src/components/ui/masonry.tsx
  • front_end/src/components/visibility_observer.tsx
  • front_end/src/constants/posts_feed.ts
  • front_end/src/contexts/feed_layout_context.tsx
  • front_end/src/contexts/global_search_context.tsx
💤 Files with no reviewable changes (2)
  • front_end/src/components/visibility_observer.tsx
  • front_end/src/contexts/global_search_context.tsx
✅ Files skipped from review due to trivial changes (1)
  • front_end/src/constants/posts_feed.ts

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.

2 participants