# Facebook-style Topic Modal - Is it better?

**URL:** https://meta.discourse.org/t/facebook-style-topic-modal-is-it-better/408565
**Category:** Development
**Created:** [July 27, 2026, 1:09am UTC](https://meta.discourse.org/t/facebook-style-topic-modal-is-it-better/408565 "2026-07-27T01:09:55Z")
**Posts on this page:** 1
**Showing post:** 21

<div class="post-metadata">

### Author: ![Don](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/don/32/228726_2.png) [@Don](https://meta.discourse.org/u/Don)
#### Post date: [August 5, 2026, 12:16am UTC](https://meta.discourse.org/t/facebook-style-topic-modal-is-it-better/408565/21 "2026-08-05T00:16:42Z")

</div>

Hello 👋

Different approach from @Damian_Boon’s though: no iframe, everything runs as native Glimmer components reusing Discourse’s own `Post`/post-stream mechanism. Since a few specific questions came up in the thread, here’s how I ended up solving each one.

* * *

**Why not an iframe**

An iframe is by far the fastest way to achieve this, full topic route, composer, moderation, all for _free_. The cost, which is what @nicolsdennis’s MessageBus question gets at, is that you end up with two live Discourse instances on the page: two post-streams, two MessageBus connections, two composers.

I went the other way. The modal is a `DModal` rendering the real `Post`/`PostSmallAction` components against a `postStream` built from `store.createRecord("topic", …)`, same app, same MessageBus connection, same composer. That means a handful of core singleton services (`modal`, `bookmarkApi`, `DiscourseURL.routeTo`, the ambient `controller:topic`) need to be pointed at the modal’s topic for as long as it’s open:

```js
// Core components (e.g. PostBookmarkManager) read/write topic.bookmarks
// from controller:topic. Point it at our topicModel while modal is open.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
  this.originalTopicControllerModel = this.topicController.model;
}

```

Every patch gets undone by an idempotent `restoreServicePatches()` on close, destroy, or navigation away, so nothing leaks into the rest of the app once the modal is gone.

* * *

**Direct links & internal routing**

Two pieces. First, the modal should open where the reader actually left off, not always at post 1:

```js
const lastRead = this.topic.last_read_post_number ?? 0;
const highestPostNumber = this.topic.highest_post_number ?? 1;
const initialPostNumber = Math.max(
  1,
  Math.min(lastRead + 1, highestPostNumber)
);

```

Second, links _inside_ posts that point back into the same topic shouldn’t escape the modal. `DiscourseURL.routeTo` gets patched for the duration it’s open:

```js
this.originalRouteTo = DiscourseURL.routeTo.bind(DiscourseURL);
DiscourseURL.routeTo = (path, opts) => {
  if (typeof path === "string") {
    const match = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/);
    if (match && parseInt(match[1], 10) === this.topicId) {
      this.jumpToPost(match[2] ? parseInt(match[2], 10) : 1);
      return Promise.resolve();
    }
  }
  this.restoreServicePatches();
  return this.originalRouteTo(path, opts);
};

```

Anything pointing elsewhere restores the patches first and falls through to a normal navigation, so leaving the modal never leaves stale routing behind.

* * *

**The reply case (20+ replies)**

Infinite scroll below via a sentinel + `IntersectionObserver`, a symmetric “load earlier” above (a preview can open mid-thread), both driving the exact `postStream.appendMore()` / `prependMore()` the real topic route uses:

```js
sentinel = modifier((element) => {
  const obs = new IntersectionObserver(
    (entries) => {
      if (entries.some((e) => e.isIntersecting)) {
        this.loadBelow();
      }
    },
    {
      root: document.querySelector(".topic-preview-modal .d-modal__body"),
      rootMargin: "200px",
    }
  );
  obs.observe(element);
  return () => obs.disconnect();
});

```

* * *

**First paint**

A skeleton (avatar + line placeholders, shimmer) fills the first paint instead of a spinner, and the post list itself renders in stages instead of all at once, just enough posts to reach the target post first, then five more at a time via `requestIdleCallback` until the rest catches up:

```js
const renderRemainingPosts = () => {
  const totalPosts = this.postStream?.posts?.length || 0;
  if (this.renderLimit < totalPosts) {
    this.renderLimit = Math.min(this.renderLimit + 5, totalPosts);
    if (this.renderLimit < totalPosts) {
      if (window.requestIdleCallback) {
        window.requestIdleCallback(renderRemainingPosts, { timeout: 300 });
      } else {
        setTimeout(renderRemainingPosts, 30);
      }
      return;
    }
  }
  this.renderLimit = Number.MAX_SAFE_INTEGER;
};

```

Each post wrapper also gets `content-visibility: auto` with a rough `contain-intrinsic-size`, so the browser skips layout entirely for posts that are off-screen:

```scss
.topic-post {
  contain: layout;
  content-visibility: auto;
  contain-intrinsic-size: 1px 180px;
}

```

* * *

> [@Damian\_Boon](#):
>
> find a way to use the first discourse loaded instance

This is the part I’d push hardest on. Instead of reusing an instance after the click, I prefetch _before_ the click. Each topic-list row watches its own visibility, debounces 200ms so a fast scroll doesn’t fire a dozen requests, and hands off to a small shared queue capped at 2 concurrent fetches:

```js
function createTopicPrefetchQueue(maxConcurrent) {
  const pending = [];
  const activeIds = new Set();
  function drain() {
    while (activeIds.size < maxConcurrent && pending.length) {
      const { id, task } = pending.shift();
      activeIds.add(id);
      Promise.resolve()
        .then(task)
        .finally(() => {
          activeIds.delete(id);
          drain();
        });
    }
  }
  return {
    schedule(id, task) {
      if (activeIds.has(id) || pending.some((item) => item.id === id)) {
        return;
      }
      pending.push({ id, task });
      drain();
    },
    cancel(id) {
      const index = pending.findIndex((item) => item.id === id);
      if (index !== -1) {
        pending.splice(index, 1);
      }
    },
  };
}

```

The prefetch is stashed under a private `PreloadStore` key, deliberately _not_ the core `topic_${id}` key, writing there would leak a `last_read+1`-scoped partial JSON into a real full-topic navigation. On the actual click it gets promoted into the key the modal’s loader expects:

```js
promoteTopicPrefetch(topic) {
  if (!topic?.id) {
    return;
  }
  try {
    const key = this.prefetchStoreKey(topic.id);
    const prefetched = PreloadStore.get?.(key);
    if (prefetched != null) {
      PreloadStore.remove?.(key);
      PreloadStore.store(`topic_${topic.id}`, prefetched);
    }
  } catch {
    // best-effort - modal falls back to a fresh load
  }
}

```

If a row scrolls back out of view before it’s clicked, the pending fetch is cancelled and any cached result discarded, so it only ever holds data for topics actually on screen right now.

* * *

**One more, not raised in the thread yet: nested modals**

A few modal flows (the ones core opens on its own rather than through an explicit callback prop wired to `showSubModal()`) call the `modal` service directly. Rather than let those close the _preview_ modal out from under the reader, `modal.show()` is patched to selectively redirect targeted modals to a small local sub-modal slot for as long as the preview is open:

```js
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
  return this.showSubModal(component, opts.model);
};

```

so those targeted flows layer on top of the preview instead of replacing it, while read-tracking (`/topics/timings`) keeps running underneath via a periodic flush of an `IntersectionObserver`-tracked set of visible post numbers, so unread counts are still correct after closing, same as a normal topic visit.

* * *

It’s a fair chunk of code across the modal, the topic-list trigger, and the styling (~1700 lines all together), this covers maybe a third of it. The trickiest parts by far were the service patching and getting `content-visibility` to behave once post heights change after images finish loading. Happy to go into more detail on any of it, or open-source it, if there’s interest.

---

_[View the full topic](https://meta.discourse.org/t/facebook-style-topic-modal-is-it-better/408565)._
