Ciao 
Un approccio diverso da quello di @Damian_Boon: nessun iframe, tutto funziona come componenti Glimmer nativi riutilizzando il meccanismo Post/post-stream di Discourse. Dato che nel thread sono emerse alcune domande specifiche, ecco come ho risolto ciascuna di esse.
Perché non un iframe
Un iframe è di gran lunga il modo più veloce per ottenere questo risultato: route completa per l’argomento, composer, moderazione, tutto gratis. Il costo, che è ciò che la domanda di @nicolsdennis sul MessageBus mette in luce, è che alla fine ti ritrovi con due istanze di Discourse attive nella pagina: due post-stream, due connessioni MessageBus, due composer.
Ho scelto la strada opposta. Il modale è un DModal che renderizza i veri componenti Post/PostSmallAction contro un postStream costruito da store.createRecord("topic", …), stessa app, stessa connessione MessageBus, stesso composer. Ciò significa che un po’ di servizi singleton di base (modal, bookmarkApi, DiscourseURL.routeTo, il controller:topic ambientale) devono essere indirizzati all’argomento del modale per tutto il tempo in cui è aperto:
// I componenti di base (es. PostBookmarkManager) leggono/scrivono topic.bookmarks
// da controller:topic. Indirizzalo al nostro topicModel mentre il modale è aperto.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
Ogni patch viene annullata da una restoreServicePatches() idempotente alla chiusura, alla distruzione o alla navigazione via, così nulla fuoriesce nel resto dell’app una volta che il modale è scomparso.
Link diretti e routing interno
Due aspetti. Primo, il modale dovrebbe aprirsi dove il lettore si era effettivamente fermato, non sempre al 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)
);
Secondo, i link all’interno dei post che puntano nuovamente allo stesso argomento non dovrebbero uscire dal modale. DiscourseURL.routeTo viene patchato per la durata in cui è aperto:
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);
};
Qualsiasi cosa che punti altrove ripristina prima le patch e passa a una navigazione normale, così uscire dal modale non lascia mai routing obsoleti.
Il caso della risposta (20+ risposte)
Scroll infinito in basso tramite un sentinella + IntersectionObserver, un simmetrico “carica precedenti” in alto (una anteprima può aprirsi a metà thread), entrambi che guidano esattamente postStream.appendMore() / prependMore() che la route reale dell’argomento utilizza:
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();
});
Primo rendering
Uno scheletro (avatar + placeholder di linea, shimmer) riempie il primo rendering invece di una rotellina, e la lista dei post stessa si renderizza in fasi invece che tutta insieme, abbastanza post per raggiungere prima il post target, poi altri cinque alla volta tramite requestIdleCallback finché il resto non si aggiorna:
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;
};
Ogni wrapper di post riceve anche content-visibility: auto con un contain-intrinsic-size approssimativo, così il browser salta completamente il layout per i post che sono fuori schermo:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
Questa è la parte su cui insisterei di più. Invece di riutilizzare un’istanza dopo il click, faccio il prefetch prima del click. Ogni riga della lista degli argomenti monitora la propria visibilità, applica un debounce di 200ms così uno scroll veloce non innesca una dozzina di richieste, e passa a una piccola coda condivisa limitata a 2 fetch concorrenti:
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);
}
},
};
}
Il prefetch viene memorizzato sotto una chiave privata PreloadStore, deliberatamente non la chiave core topic_${id}, scriverci fuoriuscirebbe un JSON parziale limitato a last_read+1 in una navigazione reale a argomento completo. Al click effettivo viene promosso nella chiave che il loader del modale si aspetta:
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 - il modale ricade a un caricamento fresco
}
}
Se una riga scorre fuori vista prima di essere cliccata, il fetch in attesa viene cancellato e qualsiasi risultato in cache scartato, così tiene solo dati per gli argomenti effettivamente sullo schermo in quel momento.
Un altro, non sollevato nel thread: modali annidati
Alcuni flussi di modale (quelli che il core apre da solo piuttosto che tramite una prop di callback esplicita cablata a showSubModal()) chiamano direttamente il servizio modal. Piuttosto che lasciare che quelli chiudano il modale anteprima sotto il lettore, modal.show() viene patchato per reindirizzare selettivamente i modali target a una piccola slot di sotto-modale locale per tutto il tempo in cui l’anteprima è aperta:
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
così quei flussi target si sovrappongono all’anteprima invece di sostituirla, mentre il tracciamento delle letture (/topics/timings) continua a funzionare sotto tramite un flush periodico di un insieme di numeri di post visibili tracciato da IntersectionObserver, così i conteggi dei non letti sono ancora corretti dopo la chiusura, come una visita normale a un argomento.
È un bel pezzo di codice distribuito tra il modale, il trigger della lista degli argomenti e lo stile (~1700 righe in totale), questo copre circa un terzo. Le parti più complesse di gran lunga sono state il patching dei servizi e far comportare correttamente content-visibility una volta che le altezze dei post cambiano dopo che le immagini finiscono di caricare. Sono felice di approfondire i dettagli su qualsiasi aspetto, o renderlo open-source, se c’è interesse.