fix: keep re-measuring rendered height instead of a one-shot onload - #78
Conversation
The dynamic height was measured exactly once, inside `window.onload`, using `offsetHeight`. Anything that changed the layout after that point was never reported back to SwiftUI, so the view kept the stale height and the content was clipped: `<details>` toggles (#18), long documents whose final layout settles after load (#59), late web fonts, rotation and Dynamic Type changes. Replace it with a `syncHeight()` that observes the container: - `ResizeObserver` on the container reports every layout change. - `MutationObserver` re-binds media handlers for nodes added later. - `toggle` is listened for in the capture phase, since it does not bubble. - `document.fonts.ready`, `load`, `resize` and `orientationchange` cover the remaining late-layout cases. - Height is `ceil(max(scrollHeight, getBoundingClientRect().height))` so fractional layout heights no longer clip the last line. - Repeated identical heights are dropped before crossing the JS/native bridge. Continuous re-measurement is only safe if the document survives a SwiftUI update, and it did not: `updateUIView`/`updateNSView` called `loadHTMLString` unconditionally on *every* state change, including the height updates this view produces itself. That reset all in-page state, so an opened `<details>` would immediately collapse again and the observers would fight the reload loop. The coordinator now remembers the loaded document and reloads only when the generated HTML actually differs, which also removes the repeated parse/layout cost on unrelated redraws (#60). Refs #18, #59, #23, #60
There was a problem hiding this comment.
🟡 Changes recommended
The new reload-suppression logic ignores conf.baseURL, so configuration changes affecting relative URL resolution may not take effect when HTML is unchanged.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes stale/clipped rendered height reporting by switching the HTML template from a one-shot window.onload measurement to continuous height synchronization, while preventing SwiftUI-driven update cycles from repeatedly reloading the document and resetting in-page state.
Changes:
- Updated
RichTextConstants.htmlTemplateto continuously re-measure height using observers/events and to avoid redundant bridge calls. - Updated
WebViewto skiploadHTMLStringwhen the generated HTML hasn’t changed, avoiding reload loops and preserving in-page state. - Added tests to pin the HTML template’s measurement strategy and
%@placeholder ordering contract.
File summaries
| File | Description |
|---|---|
| Tests/RichTextTests/RichTextSwiftTestingTests.swift | Adds contract tests for continuous height observation and placeholder ordering. |
| Sources/RichText/Views/Webview.swift | Avoids redundant reloads by caching the last generated HTML. |
| Sources/RichText/Models/RichTextConstants.swift | Replaces one-shot height measurement with ongoing observation and de-duplication. |
Review details
Suppressed comments (1)
Sources/RichText/Views/Webview.swift:297
- The reload-suppression guard only compares the HTML string; it should also consider
conf.baseURL(and then pass that same captured baseURL intoloadHTMLString) so configuration changes that affect URL resolution still take effect.
let htmlString = generateHTML()
guard coordinator.loadedHTML != htmlString else {
webViewLogger.debug("Skipping reload, generated HTML is unchanged")
return
}
coordinator.loadedHTML = htmlString
webViewLogger.debug("Loading HTML content (\(htmlString.count) characters)")
webView.loadHTMLString(htmlString, baseURL: conf.baseURL)
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// dynamic height updates this view produces itself. Reloading the document each time | ||
| /// threw away all in-page state (open `<details>`, playing media, scroll position) and | ||
| /// re-paid the full parse and layout cost, so the content could never settle. | ||
| var loadedHTML: String? |
Addresses Copilot review feedback on #78. `loadHTMLString(_:baseURL:)` resolves relative resources against the base URL, so the loaded document is a function of both arguments. Keying the reload guard on the generated HTML alone meant that changing `Configuration.baseURL` while leaving the HTML untouched was silently ignored, and relative URLs kept resolving against the previous base. Track the base URL alongside the HTML and reload when either differs.
There was a problem hiding this comment.
🟡 Changes recommended
The new reload-suppression cache in Webview.swift is not cleared on navigation failures, which can cause subsequent updates to skip reload and leave the web view stuck after an error.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
Sources/RichText/Models/RichTextConstants.swift:166
MutationObservercurrently callsattachMediaHandlers()(fullimg/videorescan) andsyncHeight()for every subtree mutation. On large/interactive documents this can fire in bursts and repeatedly scan the whole DOM; debouncing to once per animation frame reduces work without changing behavior.
new MutationObserver(function () {
attachMediaHandlers();
syncHeight();
}).observe(richTextContainer, { childList: true, subtree: true });
}
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
| /// | ||
| /// Tracked alongside the HTML because `loadHTMLString(_:baseURL:)` resolves relative | ||
| /// resources against it. Changing `baseURL` while leaving the HTML untouched has to | ||
| /// force a reload, otherwise relative URLs would keep resolving against the old base. | ||
| var loadedBaseURL: URL? |
Addresses the second round of Copilot review feedback on #78. The reload guard made a failed load sticky. `loadHTMLIfNeeded` compares against the last document handed to `loadHTMLString`, and that value was recorded even when the navigation subsequently failed, so every later SwiftUI update matched the cache and skipped the reload. The web view stayed blank with no retry. The previous unconditional reload recovered from this on the next update, so this was a regression introduced by the guard rather than a pre-existing bug. Clear both cached values in `handleNavigationError`, which restores the retry without reintroducing the reload loop for the success path.
Problem
The rendered height was measured exactly once, inside
window.onload, usingoffsetHeight. Anything that changed the layout after that point was never reported back to SwiftUI, so the view kept a stale height and the content was clipped:<details>toggled open (<DETAILS>...</DETAILS> #18)Adding continuous re-measurement alone would not have worked, because
updateUIView/updateNSViewcalledloadHTMLStringunconditionally on every SwiftUI state change, including the dynamic height updates this view produces itself. That reset all in-page state, so an opened<details>collapsed again immediately and the observers would have fought a reload loop. It also re-paid the full parse and layout cost on unrelated redraws (#60).Changes
RichTextConstants.htmlTemplate- replace the one-shotwindow.onloadmeasurement with an observedsyncHeight():ResizeObserveron the container reports every layout changeMutationObserverre-binds media handlers for nodes added latertoggleis listened for in the capture phase, since it does not bubbledocument.fonts.ready,load,resizeandorientationchangecover the remaining late-layout casesceil(max(scrollHeight, getBoundingClientRect().height)), so fractional layout heights no longer clip the last lineWebview.swift- the coordinator remembers the loaded document and reloads only when the generated HTML actually differs.Verification
Ran the old and new scripts side by side in a real browser against a
<details>repro, with thewebkit.messageHandlersbridge stubbed to record what would be posted to the native side:<details>Tests cover the template contract: the observers are present,
window.onloadis gone, and the sevenString(format:)placeholders still map to the argumentsWebView.generateHTML()supplies.Fixes #18
Fixes #59
Refs #23, #60