Skip to content

fix: keep re-measuring rendered height instead of a one-shot onload - #78

Merged
NuPlay merged 3 commits into
mainfrom
fix/dynamic-height-resync
Aug 29, 2026
Merged

fix: keep re-measuring rendered height instead of a one-shot onload#78
NuPlay merged 3 commits into
mainfrom
fix/dynamic-height-resync

Conversation

@NuPlay

@NuPlay NuPlay commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Problem

The rendered 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 a stale height and the content was clipped:

Adding continuous re-measurement alone would not have worked, because updateUIView/updateNSView called loadHTMLString unconditionally 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-shot window.onload measurement with an observed syncHeight():

  • 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

Webview.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 the webkit.messageHandlers bridge stubbed to record what would be posted to the native side:

after load after opening <details> after closing
before 91 91 (actual height 204) 91
after 91 204 91

Tests cover the template contract: the observers are present, window.onload is gone, and the seven String(format:) placeholders still map to the arguments WebView.generateHTML() supplies.

Fixes #18
Fixes #59
Refs #23, #60

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
Copilot AI lite review requested due to automatic review settings August 29, 2026 03:53
@NuPlay NuPlay added the bug Something isn't working label Aug 29, 2026
@NuPlay NuPlay self-assigned this Aug 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.htmlTemplate to continuously re-measure height using observers/events and to avoid redundant bridge calls.
  • Updated WebView to skip loadHTMLString when 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 into loadHTMLString) 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • MutationObserver currently calls attachMediaHandlers() (full img/video rescan) and syncHeight() 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

Comment on lines +135 to +139
///
/// 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.
@NuPlay
NuPlay requested a lite review from Copilot August 29, 2026 04:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@NuPlay
NuPlay merged commit 30922e7 into main Aug 29, 2026
1 check passed
This was referenced Aug 29, 2026
@NuPlay
NuPlay deleted the fix/dynamic-height-resync branch August 29, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTML content clipping <DETAILS>...</DETAILS>

2 participants