Skip to content

[2.x] perf(sticky): serve the excerpt as an attribute instead of including posts - #4882

Merged
imorland merged 2 commits into
2.xfrom
im/sticky-excerpt-attribute
Aug 2, 2026
Merged

[2.x] perf(sticky): serve the excerpt as an attribute instead of including posts#4882
imorland merged 2 commits into
2.xfrom
im/sticky-excerpt-attribute

Conversation

@imorland

@imorland imorland commented Aug 2, 2026

Copy link
Copy Markdown
Member

Part 1 of the index firstPost work (context: discuss profiling — the ~110ms/23-query/123KB item). fof/synopsis and fof/gamification follow separately.

The problem

Sticky default-included firstPost on the discussions index to show a 175-char plain-text excerpt on stickied rows. Including a post serializes it in full: contentHtml renders 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 and enable_display_excerpt defaults to on, virtually every Flarum forum pays this.

The change

Stickied discussions carry a firstPostExcerpt attribute instead:

  • Plain text via 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).
  • Visible only on stickied rows with the setting enabled.
  • First posts load through the relationship buffer (EloquentBuffer::add/load, the same pattern as countRelation): 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.
  • JS reads the attribute; the include push 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 eagerLoadWhere to sticky rows' first posts. Measurement showed included posts dropping further than expected — because the constrained load marks firstPost as loaded (null) on every non-sticky row, and a pre-loaded relation short-circuits include resolution. Any client explicitly requesting include=firstPost for 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.

imorland and others added 2 commits August 2, 2026 10:24
…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.
@imorland
imorland requested a review from a team as a code owner August 2, 2026 09:24
@imorland imorland changed the title perf(sticky): serve the excerpt as an attribute instead of including posts [2.x] perf(sticky): serve the excerpt as an attribute instead of including posts Aug 2, 2026
@imorland imorland added this to the 2.0.0-rc.6 milestone Aug 2, 2026
@imorland
imorland merged commit 59194cc into 2.x Aug 2, 2026
25 checks passed
@imorland
imorland deleted the im/sticky-excerpt-attribute branch August 2, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants