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:
// 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:
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:
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:
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:
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:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
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:
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:
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:
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.