Status: landed on
develop2026-08-15, 24 of 27 items fixed. Fela tested the series in the app against the message set below and confirmed the behaviour, including the relative date and spoiler cases that were visibly broken beforehand.Three items are still open, all of them decisions rather than patches — the non-pooled unload/reload teardown (P2), the
WalkInlinesenumerator (P3, wants a profiler) andHasLineEnding's commented-outInvalidateMeasure(P3, belongs with the layout-cycle audit). Each says under its own entry what it is waiting on.
formatted-text-block-test-plan.mdandformatted-text-block-test-messages.pybeside this file reproduce the test set; run the script immediately before testing, since the relative dates are anchored seconds old on purpose.
Read-through of Telegram/Controls/FormattedTextBlock.cs (2,704 lines) and
Telegram/Controls/FormattedTextBlock.Selectable.cs (405), cross-checked against
Telegram.Native/Controls/FormattedTextBlockBase.{h,cpp}, the host
Telegram/Controls/Messages/MessageTextBlock.cs, Telegram/Common/TextStyleRun.cs
(StyledText/StyledParagraph), the template in Telegram/Themes/Generic.xaml:2367
and the call sites in Controls/Cells/, Controls/Messages/ and Controls/Chats/.
Line numbers are as of 73b3ec369 (the three files are untouched since c23b13d6d), i.e.
before the P0 commits below; anything still open has shifted by ~17 lines since.
Legend: [live] = confirmed reachable from current app code, with the call site named · [latent] = correct today only by convention or because no caller exercises it.
The three facts most of this rests on:
MessageTextBlockhands each child block a paragraph range of a sharedStyledText(MessageTextBlock.cs:313-354). Every offset inStyledText.Textis absolute for the whole message; the rendered/TextHighlighterindex space is per block and starts at 0._indexMapis the only thing that reconciles the two.- Only
MessageBubble.Messagegets aRecyclePool(MessageBubble.xaml.cs:190/278). Every otherFormattedTextBlockin the app —ChatCell.BriefText,WebPageContent,PollContent,MessageReply.Label,ChatPinnedMessage, … — runs with_pools == null, which changes whatOnLoaded/OnUnloadeddo. - The app runs several XAML threads (see the
[ThreadStatic] RelativeDateServiceat:2543). Anything cached statically here must be a value type or[ThreadStatic]—Brush/FontFamilyareDependencyObjects and thread-affine.
-
The fast path drops the index map for blocks that don't start at paragraph 0 —
FormattedTextBlock.cs:1005-1007[live] → fixed in the commit that checked this box (git log --follow formatted-text-block-review.md)```csharp // Plain single run: rendered index == styled offset, so the converter's // 1:1 fallback is exact — no map needed. _indexMap = null; ``` The identity only holds when `_first == 0`. The fast path is entered whenever `rangeStart == rangeEnd` and that paragraph `IsPlain` (`:929`), and `MessageTextBlock` emits exactly that shape for **every single normal paragraph sandwiched between typed ones** (`MessageTextBlock.cs:230/234`) — e.g. a message of `code block / plain line / code block` gives the middle block `_first == _last == 1`. A fresh block has `_plain == true` and `HasCodeBlocks == false`, so the `_plain == prevPlain` guard does not save it. With a null map, `RenderedToStyled` and `StyledToRendered` (`Selectable.cs:278/311`) return the rendered index unchanged, so everything built on them is off by `styled.Paragraphs[_first].Offset`: - `GetSelectedText` (`Selectable.cs:168`) — selecting that line and copying yields text from the **start of the message**, of the right length. - `GetSourceOffset` (`:182`) — the cross-block range `TextSelectionManager` builds is anchored at the wrong paragraph. - `GetSelectionBoundary` (`:199`) — double-click word / triple-click paragraph resolves against the wrong paragraph, so `ParagraphRange` returns another paragraph's `[lo, hi)`. - `ApplyHighlighters` (`:505`) — `StyledToRendered(find)` places the search highlight past the end of the block's content. Fixed with an `_origin` field — the styled offset that rendered index 0 maps to — set next to `_first`/`_last` in `SetText`, so it is right before every early return and on both paths, and consulted only by the no-map fallback in `RenderedToStyled` / `StyledToRendered`. The fast path stays allocation-free. Setting it in `SetText` rather than in the fast-path branch also covers the case the original note missed: the slow path can leave `_indexMap` **empty** (every entity skipped by the `entity.Length + entity.Offset > text.Length` guard at `:1141`), which takes the same fallback. -
ProcessCodeBlock's execution guard is an ABA —:941,:886,:1931[live] → fixed in the commit that checked this box`var execution = ++_templateExecuted;` (`:941`) versus `_templateExecuted = 0;` in `OnUnloaded` (`:886`). The counter restarts, so `execution == 1` is handed out again after every unload. A tokenization started before the unload (`SyntaxToken.TokenizeAsync`, an `await` that can outlive a scroll) resumes, finds `_templateExecuted == 1` and passes the "still the same content" test for **different** content. What it then does is worse than a stale repaint: `inlines` is the `Paragraph_Inlines` collection of a paragraph that `Recycle` (`:807-812`) has already returned to the shared `FormattedTextBlockRecyclePool`, so `direct.ClearCollection(inlines)` (`:1940`) wipes whichever block dequeued it, and `ProcessCodeBlock` fills it with the old message's syntax spans. Only pooled blocks (message bubbles) are affected, which is also the only place code blocks scroll fast. Fixed with a second, never-reset `_generation` counter for the async guard. `_templateExecuted` kept only its other job — the "text already applied" flag `OnLoaded` tests, which is exactly why it has to be cleared on unload — so it became `bool _textApplied`.
-
_lightand_darkare instance fields —:2023and:2054[live] → fixed in the commit that checked this boxTwo `Dictionary<string, Color>` of 27–28 entries, built **per `FormattedTextBlock`**, i.e. per message block, whether or not the message contains code. Colors are structs and the tables are constant, so `static readonly` is a straight win (≈3 KB per instance) with no thread affinity to worry about. `_brushes` (`:2087`) must stay per-thread at least, because `SolidColorBrush` is thread-affine, but it can be allocated lazily on the first `GetColor` call instead of in the field initializer — only code blocks ever touch it. Both done: the tables are `static readonly` (never mutated — `OnActualThemeChanged` writes through to the *brushes*, not the tables), and `_brushes` is now null until `GetColor` needs it, with the theme handler returning early when it is. -
monospaceFontFamilyis never assigned —:1060-1064[live] → fixed in the commit that checked this box```csharp FontFamily monospaceFontFamily = null; FontFamily GetMonospaceFontFamily() { return monospaceFontFamily ?? new FontFamily("Cascadia Mono, Consolas, " + Theme.Current.XamlAutoFontFamily); } ``` The local is only ever read, so the memoization does nothing: every inline-code entity in the paragraph allocates a fresh `FontFamily` plus the concatenated string. Same in the recursive `ProcessCodeBlock` (`:1956`) — `new FontFamily(...)` is built once per **token node**, so a syntax-highlighted block allocates one per span in the tree. Worth remembering that a font chain is not free at use either: see the "packaged font fallback cost" note — RichEdit pays ~1 ms per run for a chain whose first entry misses. Fixed one level up, on Fela's call: the chain is now `Theme.MonospaceFontFamily`, built in `UpdateEmojiSet` beside the other families — which is also the only place it can change — and every site in the app reads it. That covers the eight other call sites, six of which (`TextBlockHelper.cs:226/347`, `GameContent.xaml.cs:240/245`, `ProfileHeader.xaml.cs:1735/1740`) were allocating one `FontFamily` **per code entity inside a render loop**. Those six built `"Cascadia Mono, Consolas"` with no fallback tail, so they now inherit one: a character the monospace faces don't cover (an emoji in a code span in a bio) will render instead of dropping to the system default. Deliberate, but it is a rendering change — say so if you'd rather keep two chains. -
OnPointerMovedre-sets the cursor on every sample —:322-331[live] → fixed in the commit that checked this box```csharp if (hyperlink == null) { _textSelectionIBeam = true; Window.Current.CoreWindow.PointerCursor = new Windows.UI.Core.CoreCursor(CoreCursorType.IBeam, 0); } ``` `_textSelectionIBeam` is written but never tested, so moving over message text allocates a `CoreCursor` and writes `CoreWindow.PointerCursor` at pointer sample rate. Fixed by testing the flag — with the part the note missed: the flag also has to be **cleared** over a hyperlink. The `Hyperlink` puts its own `Hand` cursor up, so ours is no longer on screen, and gating on a flag that stayed `true` would skip restoring the I-beam on the way back onto text and leave the `Hand` there. The remaining `CoreCursor` allocations (`Arrow` at `:316`/`:340`/`:346`, `Hand` at `:304`) are all edge-triggered already, so they're left alone. -
MeasureOverrideruns a native text measure per pass for expandable quotes —:256-277[live] → fixed in the commit that checked this box`PlaceholderHelper.Foreground.MaxLines(...)` on every measure, with no memo on (text, width, size). Quote blocks re-measure with the rest of the bubble on every window resize and on every `InvalidateMeasure` from the panel. Fixed by keeping those three inputs and skipping the call when they match. The text is compared by reference, which is exact here rather than a heuristic: `GetParts` returns the same string until a relative date rewrites the paragraph, and that is precisely when the measurement can change. `TextBlock` is also null-checked now, since the `FontSize` read on the `AutoFontSize == false` path had nothing guarding it. Related: `IsTextTrimmableChanged` is raised **from inside** `MeasureOverride` (`:271`) and `BlockQuote.OnIsTextTrimmableChanged` (`BlockQuote.cs:62`) reacts by changing state that feeds `ComputedIsExpandable`, which `MessageTextBlock.ArrangeOverride` reads (`MessageTextBlock.cs:445`). That is the shape the layout-cycle audit is about; worth checking it can't re-enter. -
Seven collections per instance, before any content —
:97-99and:555-558→ fixed in the commit that checked this box`_links`, `_dates`, `_spoilers` + four `HashSet`s for the active elements. The vast majority of blocks have no hyperlink, no spoiler and no date. Lazy `??=` at the first `Add` costs a null check on a path that is already doing XAML interop. While there: the four `_active*` sets only need `Contains`/`Remove` for `_activeRuns` (`:1934`, the async code path). `List<T>` is cheaper to fill and to enumerate for the other three, and `_activeRuns`' single linear scan happens once per tokenized code block. Done, with the split the note implies: `_links`/`_dates`/`_spoilers` are lazy (they have an `Add` site and a teardown site each, so the guards are few), while `_activeRuns` and `_activeParagraphs` stay eager because every rendered block fills them — laziness there would be six null checks for nothing. All four became `List<T>`. The set → list swap rests on elements never being added twice, which holds: each is either newly constructed or dequeued from the pool (so removed from it), `Recycle` clears the lists, and the one place that hands an element back mid-render (`ProcessCodeBlock`'s placeholder) removes it from the list before enqueuing it.
-
A block with no
RecyclePoolnever rebuilds after unload/reload —:864vs:887[latent] — needs a repro before fixing`OnUnloaded` calls `ClearEntities()` unconditionally (`:887`) — tooltips detached, relative dates unsubscribed, `_spoilers` emptied, `_effectiveViewportChanged` dropped and the viewport registration revoked — and only *then* returns early for `_pools == null` (`:889`), leaving the inline tree in place. `OnLoaded` then returns early for the same reason (`:864`), so nothing re-runs `SetText`. The visible tree still shows the right text, but: link tooltips are gone, relative dates freeze, custom emoji stop being told about the viewport, and `_spoilers` is empty while the transparent `_spoiler` highlighter is still applied — so the next `UpdateSpoilers` (any inner size change reaches it through `HandleSizeChanged`, `FormattedTextBlockBase.cpp:73-77`) removes the particle overlay and leaves the spoilered text *invisible* rather than revealed. Most non-pooled hosts re-`SetText` on reuse, which is why this hasn't shown up; the ones to check are those that unload/reload without re-setting text (popups, pivots, `ChatPinnedMessage`). → **left open deliberately.** Both fixes the note proposes cost something real, and picking between them blind is worse than leaving the bug: - *Move `ClearEntities()` behind the early-out* — non-pooled blocks would keep their tooltips, date subscriptions and viewport registration across an unload. But a block that unloads and never comes back then stays subscribed to the thread-static `RelativeDateService`, which pins the block and everything it references. Trading a stale-state bug for a leak. - *Let `OnLoaded` re-apply regardless of `_pools`* (drop `|| _pools == null` from `:864`) — correct, but it puts a full `SetText` on every reload of every non-pooled block, and the biggest population of those is `ChatCell.BriefText`. The chat list already re-sets text on reuse, so that is a second full rebuild per recycled row on the app's hottest list. What decides it is which hosts actually reload without re-setting text. That is a repro away, not an argument away. -
Relative dates can stall for good —
:2600-2648[live] → fixed in the commit that checked this boxIn `GetNextUpdateInterval`, an item that isn't due yet contributes `remainingSeconds = (long)(item.NextUpdateAt - tickCount) / 1000` (`:2640`) and is skipped when that truncates to 0. If every item is within a second of its deadline — the normal case for the `< 60s` bucket, where the interval is 1 s and `DispatcherTimer` can fire a few ms early — `minSeconds` stays `int.MaxValue` and the timer is rearmed **68 years out**. Nothing else restarts it, so every relative timestamp on the thread freezes until another `Subscribe` happens. Fixed both ways the note suggests, because they cover different holes: the not-due branch rounds up instead of truncating, and the return clamps `int.MaxValue` to a second so an empty or fully-skipped set can't arm the timer past the heat death of the sun. -
record TextDate—:2494[live] → fixed in the commit that checked this boxThe project rule is that .NET Native has no records. It is also the wrong shape: it's a mutable dictionary *value* (`NextUpdateAt { get; set; }`) that never needs value equality, and the generated `Equals`/`GetHashCode`/`PrintMembers` walk five reference members. A plain class is smaller and generates nothing. Now a plain class. `EntityType` went with it: it was a positional member only because the constructor needs it to compute `Date`, and nothing ever read it back. -
Revealing a spoiler wipes the search highlight —
:435-436[live] → fixed in the commit that checked this box```csharp SetText(_clientService, _text, _first, _last, _fontSize); SetQuery(string.Empty); ``` `SetText` already calls `ApplyHighlighters` (`:1584`), so the `SetQuery(string.Empty)` is not needed to repaint — it only sets `_query = ""`, dropping the in-message search highlight (`MessageBubble.xaml.cs:147`) when the user taps a spoiler. Delete the line. -
ApplyHighlighterssilently drops everything when the inner block isn't loaded —:482[live, but pre-existing] → fixed in the commit that checked this boxThe `!TextBlock.IsLoaded` early-out predates the refactor (it was `SetQuery`'s), but it now gates the spoiler/cached/marked highlighters too, and there is **no re-apply on load**: `OnApplyTemplate` calls `SetText` (`:247`), which sets `_templateExecuted = 1`, and `OnLoaded` returns early precisely on `_templateExecuted > 0` (`:864`). So if `RichTextBlock.IsLoaded` is false during template application — which is what the guard exists for — a first-render spoiler is never covered. Spoilers do work today, so `IsLoaded` is presumably already true at that point and the guard is dead weight; worth confirming, then either dropping the guard or setting a `_highlightersPending` flag that `OnLoaded` honours. As written the behaviour depends on an undocumented ordering. Took the flag rather than dropping the guard, since that is the option that is right either way: if `IsLoaded` is already true at template time nothing changes at all, and if it isn't, the highlighters now arrive on load instead of never. The ordering question itself stays open — this makes the answer stop mattering. -
Date-driven spoiler fix-up loses deltas and never touches the ranges —
:2508-2519[latent][live] → fixed on theformatted-text-block-datesbranch`TextDate.Update` re-derives each spoiler from `OriginalOffset` plus *its own* delta, so with two relative dates before the same spoiler the second update overwrites the first's shift. And the highlighter ranges themselves are left alone (the commented-out block at `:2521-2532`), so the transparent range drifts off the text whenever a date changes length (`"59 seconds ago"` → `"1 minute ago"`). **Marked [latent] on a wrong reading.** I traced where `TextEntityTypeDateTime` is constructed, found only Unigram's own sites (`TryParseDateTime` on a `tg://date` link and the `x-tl-field-tags` clipboard reader, both feeding the composer, plus `PageBlockHelper` for Instant View) and concluded the read view only saw dates in IV. It is a **TDLib type** — `td_api.tl:5759` — so it arrives in ordinary message entities, and this is live on the message path. That also promotes a third consumer nobody was tracking: `_indexMap`. A date's segment records `FormattedText.Length` at build time, so once the date is rewritten every segment after it is off — which is selection and copy in any message containing a relative date, independent of spoilers. The three consumers are fixed by two different mechanisms, because they want different things: - **Geometry** (`_spoilers`) is now *derived*, not stored. `TextStyleSpoiler` keeps source offsets and `DisplayedRange` computes the displayed range from the paragraph's runs at the point of use: dates ending before the spoiler push it along, a date inside it stretches it. Any number of dates, any order, no state to drift. The whole patch loop in `TextDate.Update` is gone, along with the second constructor and the `dates` accumulator in `SetText`. - **Rendered space** (`_indexMap`, and the spoiler/marked/cached ranges) is *shifted*, by `ShiftRenderedSpace(segment, delta)`. Each date carries the index of its own map segment, captured when `SetText` mapped it, so a tick moves exactly what is downstream of it. The delta is measured **since the last tick** — the old code measured against `Entity.Length`, the source length, which is the total growth since first render, so applying it every tick and once per date compounded. **A spoiler cannot actually contain a date**, found while building the test set: send one that does and the server splits the spoiler around the date (`spoiler("A ") date("X") spoiler(" B")`). So the "a date inside it stretches it" half of `DisplayedRange`, and the stretch branch of `ShiftRanges`, are never taken for a date. They stay live for the marked and cached highlighters, which the app builds itself and which can span anything — but for spoilers the real case is the *split*, two covers either side of a date, where the one in front must hold still while the one behind moves. That is what T10 in the test plan sends. Two things fixed on the way, both from the same root: - A spoiler's rendered range was `Length = entity.Length` — the *source* length. A spoiler wrapping a date renders longer than its source, so the cover was short from the first render. The range is now measured from what was actually emitted (`offset` before and after), which is right for emoji and math inside a spoiler too. - The two branches of `UpdateSpoilers` disagreed about which space the offset was in: the block branch measures against the date-expanded `GetParts` text, the inline branch against raw `_text.Text`. The stored offset carried the date shift, so it was wrong in the inline branch by construction. Deriving per branch settles it. `// TODO: get rid of _spoiler` (`:1580`) is still the better end state — none of the rendered-space shifting would be needed if the cover came from the geometry — but that is a design change, and this makes the current design correct. -
Nothing guarantees a block ever releases its relative dates —
MessageTextBlock.cs:294,FormattedTextBlock.cs:909[live] — raised by Fela, not found by the review → fixed in the commit that checked this boxThe review checked that `ClearEntities` unsubscribes and stopped there. It never asked what guarantees `ClearEntities` runs, and for the blocks inside a `MessageTextBlock`, nothing does. `ClearBlocks` drops them with `Children.Clear()` and leaves teardown to the `Unloaded` event — its own comment says so. `FrameworkElementEx` only raises `OnUnloaded` for an element it saw `Loaded` on (`FrameworkElementEx.h:37-49`). A block subscribes during `SetText`, which runs from `OnApplyTemplate`, which runs on the first **measure** — before `Loaded`. A block measured and then dropped before its `Loaded` arrives therefore never releases anything, and what it holds is a registration in the `[ThreadStatic]` `RelativeDateService`: that pins the block, its `StyledParagraph`, its `TextStyleRun` and the run object for the rest of the session, and the timer goes on ticking it and calling `RegisterLayoutChanged` on a control that is not in the tree. Its runs never return to the pool either. **The half that needs no assumption about `Loaded`:** the service is keyed by the *run object*, and runs come from the shared pool. `SubscribeImpl` returned early when the key already existed, so a single registration that outlives its block makes that pooled run **permanently unsubscribable** — every later block that dequeues it has its date silently never update. One leak poisons a pooled object for the session. Fixed at both ends: `ClearBlocks` calls `Clear()` on each block before dropping it, so teardown is deterministic instead of event-dependent, and `SubscribeImpl` replaces the entry rather than skipping, since a run being resubscribed always means the old registration is dead. Left alone: the runs still return to the pool via `OnUnloaded` only. A missed pool return costs an allocation, not a pinned graph, and making `Clear` recycle as well would put two paths on the same pool — worth doing only with the recycling audit, not blind.
-
The two
GetOrCreateRunoverloads are the same 80 lines twice —:618-699and:701-782. The only difference istext.Substring(offset, length)vstexton the pooled path; the non-pooled path already forwards to twoNativeUtilsoverloads. The range overload can call the other with the substring, or both can share a privateApplyRunProperties(direct, run, ...). → fixed in the commit that checked this boxTook the shared helper, not the forwarding, because forwarding would have cost the range overload its one real advantage: on the path that builds a new `Run`, `NativeUtils` takes the offset and length and never materializes the substring. Each overload keeps its own `Run_Text` line and its own native call, and the 60 lines of property resetting between them is now written once. Net -59 lines, no behaviour change. -
—Clear()has no callersClear()doesn't clear —:386-396[live] → fixed in the commit that checked this box**This entry was wrong.** `Clear()` is not dead: `MessageService.Recycle` calls it on the `Text` block of every service message (`MessageService.cs:121-124`), and `MessageGiftContent` calls it on `Subtitle`. The original grep looked for the control's field names and missed a `FindName("Text") is FormattedTextBlock content` and a `Subtitle.Clear()`. That turns the second half of the note from a hypothetical into a live bug, and against the contract written directly above the call site — *"whatever isn't reset is inherited by it"*. `Clear` nulled `_query` and `_spoiler` but never touched `TextBlock.TextHighlighters`, and left `_cached`/`_marked`/`_selection` entirely, so a recycled service-message container could show the **previous** message's spoiler cover and highlights over the new text. Fixed by dropping all four sources and calling `ApplyHighlighters`, which is what takes them off the control. -
The
SetQuery(string.Empty)calls in the cells are now no-ops —ChatCell.xaml.cs:1526,ForumTopicCell.xaml.cs:557,BusinessChatLinkCell.xaml.cs:37,ChatPinnedMessage.xaml.cs:422,MessageReply.xaml.cs:433,ProfileHeader.xaml.cs:1054/1391. With_querynull, the guard at:466returns immediately; they only existed to trigger the old "apply highlighters" pass. → closed as keep, no code changeThey are only no-ops because nothing gives those controls a query today — the one live query in the app is `MessageBubble` → `MessageTextBlock` (`:147`). Each of these sits on a recycle path, where the call is the reset that would matter the moment a query reached a cell, and where it costs one string comparison that returns immediately. Deleting seven lines across seven files to save that is the wrong trade. -
SetFontSizeonly reaches the first paragraph —:451-459. It setsTextBlock.Blocks[0].FontSize, so a multi-paragraph block keeps the old size on paragraphs 2..n; and on the fast path theRuncarries its ownFontSize(:977), which wins over the paragraph's, making the call a no-op there. → closed as by design (Fela), no code changeThe method exists for the mockup bubbles only: its single caller is `MessageBubble.UpdateMockup` (`MessageBubble.xaml.cs:3712`, the call at `:3770`), which renders the fake conversation in appearance settings where the font-size slider has to take effect without going back through `SetText`. Those messages are one plain paragraph each, so "the first paragraph" is the whole text and there is nothing for a loop to reach. Which also settles the quote half: `SetFontSize` never sees a quote, so it cannot clobber the caption size, and the TODO at `:1127` stays a question about `SetText` alone. -
Selectable.cs's header comment contradictsWalkInlines—Selectable.cs:38-40says the highlighter space counts "1 per inline object (custom emoji, image, math)", butcase InlineUIContainer: break;(:394-395) counts 0, which is whatSetText'sMap(..., 1, entity.Length) // emoji (container=0 rendered) + ZWNJ(:1406) assumes. The code is self-consistent; the comment is the thing that will mislead. → fixed in the commit that checked this box; the comment now says an inline object counts 0 and explains why (the ZWNJ beside it is the unit that stands in for it). -
Inline mode misses
textOffseton the query highlight —:505vs:1280[latent]. The spoiler branch adds_spanForInlines.ContentStart.OffsetToIndex(TextBlock)to its ranges; the query branch doesn't, so a search highlight in aChatCellbrief would land short by the length of the"Fela: "prefix. Latent only because no caller passes a non-empty query to an inline-mode block today (the only real query isMessageBubble→MessageTextBlock). → fixed in the commit that checked this boxFixed to match the spoiler branch. Worth knowing it is **unexercised**: no caller and no test reaches it, so it is right by symmetry with code that works, not by observation. If chat-list search highlighting is ever wired up, check that line first. -
RenderedToStyled/StyledToRenderedare linear —Selectable.cs:278/311. One segment per run, scanned per pointer move during a drag (GetSelectionBoundary,GetSelectedText). Segments are sorted and non-overlapping in both spaces, so a binary search is a two-line change; worth it for long code blocks, where the segment count is the token count. → closed as not worth it, no code changeThe last sentence was wrong, and it was the whole argument. `_indexMap` is built by `SetText`'s entity loop only — `ProcessCodeBlock` adds spans and runs but never segments, so a syntax-highlighted block contributes **one** segment, not one per token. Real maps run to a handful of entries, where a linear scan beats a binary search. Left alone. -
WalkInlinesallocates an enumerator per level, per pointer move —Selectable.cs:367.foreachoverInlineCollectiongoes through the projectedIEnumerable<Inline>. Indexing is not obviously better (each[i]is its own interop call) — measure before changing, but note it sits on the same path_contentLength(:85-89) was cached for. → left open: the one item here that wants a profiler.Both shapes are interop-bound and which wins depends on how the projection caches its enumerator, which is not something to settle by reading. The cheap experiment is a drag across a syntax-highlighted code block — the deepest inline tree the app builds, and the case `_contentLength` was already cached for. -
OnHyperlinkForegroundChangedandOnCodeForegroundChangedare identical —:2252-2261and:2281-2290. Both walk all hyperlinks and recolor those whoseForegroundmatches the old value, so if the link brush and the code brush are ever the same instance, one property change recolors both kinds. Tag the hyperlink kind, or keep the code links in their own list. → merged into oneRecolorHyperlinksin the commit that checked this boxOnly the duplication is fixed. Tagging the kind would mean a second list on a hot path to close a case that needs the two brushes to be the *same instance*, which no style in the app does — so the comment says it instead of the code paying for it. -
Naming, while in here —
var hyperlink = GetOrCreateSpan(direct)for spoilers (:1201,:1268),foreach (var hyperlink in _spoilers)overTextStyleSpoilerstructs (:1732,:1796), andTextStyleRun Yoloin theRelativeDateServicesignatures (:2559,:2565). → renamed tospan,spoilerandrunin the commit that checked this box`GetNextUpdateInterval` still updates the run texts as a side effect of a `Get`, which is more than a rename — that call is the only thing driving the updates at all, so moving it means restructuring the timer, not renaming a method. -
Blocks+ re-templating —:208-212/:231-240._blocksis never cleared after its paragraphs move intoTextBlock.Blocks, and the loop casts withas Paragraphwithout a null check, so a secondOnApplyTemplate(theme/style change) would either re-parent the sameParagraphs or addnull. → fixed in the commit that checked this box`_blocks` is dropped once handed over, which is safe because `Blocks` reads through to `TextBlock.Blocks` from that point on, and a non-`Paragraph` entry is skipped rather than added as null. -
TextBlockis assumed non-null outsideSetText—MeasureOverride:263,UpdateSpoilers:1713,InvalidateSkeleton:2426. All three readTextBlock.FontSizeonly whenAutoFontSizeis false, which is why it hasn't fired;SetTextguards with_templateAppliedbut these don't. → guarded inMeasureOverrideonly (with the measure-memo commit); the other two closed as unreachable`UpdateSpoilers` and `InvalidateSkeleton` run only from `OnLayoutUpdated`, which fires from a `LayoutUpdated`/`SizeChanged` registration made on `m_textBlock` itself (`FormattedTextBlockBase.cpp:37-43`). With no template child, `RegisterLayoutChanged` would have dereferenced null in C++ long before either could run. `MeasureOverride` is the one XAML can reach on its own, so it is the one that got a check. -
HasLineEnding'sInvalidateMeasureis commented out —:173-184, read byMessageBubblePanel.cs:253. Fine whileSetTextalways precedes measure; aSetTextafter layout (spoiler reveal, relative-date rebuild) won't re-measure the bubble. → left open. Uncommenting it is one line, but it puts anInvalidateMeasureon a property thatSetTextwrites on every render, and the panel that reads it is the message bubble's own layout. Whoever commented it out was most likely avoiding exactly that, and the layout-cycle audit is the place that question belongs.