[2.x] perf(sticky): serve the excerpt as an attribute instead of including posts - #4882
Merged
Conversation
…posts Sticky default-included firstPost on the discussions index to show a 175-character plain-text excerpt on stickied rows. Including a post serializes it in full: the formatter renders its HTML through every extension's render callbacks, its visibility policies run, and the whole rendered content ships in the payload — for every discussion on every index view, on the extension nearly every forum runs with the excerpt setting on by default. Measured on a 74-extension install this was the largest single cost of the index route. Stickied discussions now carry a firstPostExcerpt attribute instead: plain text extracted straight from the stored XML (no render pipeline, no callbacks, no policies), capped at 200 characters. The first posts load through the relationship buffer — one batched query for the stickied rows on a page, nothing at all for the rest, and no query when the excerpt setting is off. The buffer matters for compatibility, not just batching: constraining an endpoint eager load instead would mark firstPost as loaded (null) on every non-sticky row, breaking clients that explicitly include firstPost for all discussions — fof/synopsis does exactly that. A regression test pins the contract: an explicit include still serializes every row's first post. The frontend reads the attribute and no longer pushes the include; the server-rendered index document gains excerpts for free since attributes serialize with the discussion.
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 1 of the index
firstPostwork (context: discuss profiling — the ~110ms/23-query/123KB item). fof/synopsis and fof/gamification follow separately.The problem
Sticky default-included
firstPoston the discussions index to show a 175-char plain-text excerpt on stickied rows. Including a post serializes it in full:contentHtmlrenders through every extension's render callbacks, its visibility policies run (canEdit/canHide/canFlag— the PostPolicy chain), and the rendered HTML ships in the payload — for every discussion on every index view. Since sticky is bundled andenable_display_excerptdefaults to on, virtually every Flarum forum pays this.The change
Stickied discussions carry a
firstPostExcerptattribute instead:s9e\TextFormatter\Utils::removeFormatting()straight from the stored XML — no render pipeline, no callbacks, no policies. Capped at 200 chars (the frontend truncates to its display length).EloquentBuffer::add/load, the same pattern ascountRelation): one batched query for the stickied rows on a page, nothing for the rest, zero queries when the setting is off or no stickies are on the page.includepush is gone. The server-rendered index document gets excerpts for free since attributes serialize with the discussion.Why the buffer and not a constrained eager load — the part worth reviewing
My first version constrained an endpoint
eagerLoadWhereto sticky rows' first posts. Measurement showed included posts dropping further than expected — because the constrained load marksfirstPostas loaded (null) on every non-sticky row, and a pre-loaded relation short-circuits include resolution. Any client explicitly requestinginclude=firstPostfor all discussions would silently get nulls — which is exactly what fof/synopsis ships today. The compat test (explicitly_requesting_first_posts_still_serializes_them_for_every_row) was written RED against that version and pins the contract.Numbers
On a sticky-only install (integration tests): index payload goes from 20 serialized posts to zero; excerpt costs one batched query, none with the setting off.
On heavier installs the win is partially masked until other extensions stop forcing the include themselves — fof/geoip's
addDefaultInclude(['firstPost.ipInfo'])still does (nothing on the list UI consumes it); follow-up PR queued there.Testing
5 new integration tests, the payload-shape and attribute tests RED first: no posts serialized by default, excerpt only on stickied rows with formatting stripped, one batched query, zero queries with the setting off, and the explicit-include compatibility contract. Sticky suite 26/26, core discussions suite green, PHPStan clean. Verified live: excerpts render on stickied rows exactly as before.