Skip to content

feat: warn about JavaScript invocations that are never delivered to the client - #25252

Open
totally-not-ai[bot] wants to merge 6 commits into
mainfrom
feat/warn-about-undelivered-js-invocations
Open

feat: warn about JavaScript invocations that are never delivered to the client#25252
totally-not-ai[bot] wants to merge 6 commits into
mainfrom
feat/warn-about-undelivered-js-invocations

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

JavaScript invocations scheduled from the server are retained in memory until they are written into a response for the client, so an application that keeps scheduling invocations that are never delivered runs out of memory. Nothing reports this today: the developer sees it for the first time as hundreds of thousands of PendingJavaScriptInvocation and StateTree$BeforeClientResponseEntry instances in a heap dump, with no indication of which component schedules them or why they are stuck. See this forum thread for how hard this is to diagnose from the outside, and #17286 for the related case of invocations retained for invisible components.

Invocations pile up in three different places, which is why counting only the queue in UIInternals would not have covered the reported case:

  • Element.executeJs parks the invocation in a beforeClientResponse entry on its state node, and those entries are only flushed while a UIDL response is being written. Without a response, the invocations never even reach UIInternals.
  • An executeJs call on a detached node registers an attach listener that holds the invocation until the node is attached again.
  • UIInternals.pendingJsInvocations grows through Page.executeJs, and invocations owned by an invisible component are deliberately re-queued on every purge.

What changed

  • Counting at the source. The invocations that have been scheduled for a UI without being sent are counted in UIInternals, hooked into the PendingJavaScriptInvocation lifecycle: the constructor counts one, and setSentToBrowser() / cancelExecution() uncount it. Counting happens when an invocation is scheduled rather than when it enters the UI queue, so it covers all three cases above, including the invocations that never reach that queue. Each invocation remembers which UI counted it, so the same counter is decremented later even if its owner has been detached meanwhile. The counter is deliberately not kept per state node: a running application has far more nodes than UIs, so even one field per node multiplies into a significant amount of resident memory for nodes that never schedule any JavaScript.

  • A warning that identifies the culprit. PendingJavaScriptInvocationUtil logs a warning when a UI reaches 1000 undelivered invocations, and repeats it only when the count grows tenfold, so a leaking application logs a handful of lines instead of thousands. The message names the component the most recently scheduled invocation belongs to (plus its creation location when component tracking is enabled), its expression, and whether that owner is detached, invisible, or in a UI with no open push connection. Debug logging adds the executeJs call site. Example, from a UI with push disabled that keeps updating a field:

    1000 JavaScript invocations scheduled for com.example.MyField (state node 3) have not been sent to the browser yet. The most recent expression is: 'return (async function() { this.value = $0}).apply($1)'. There is no open push connection for the UI (push mode DISABLED), so nothing can be delivered until the browser sends a request. A closed browser tab whose session has not expired yet, or a push connection that was never established, look exactly like this. […]

  • Advice for the common cause. The message explains what to do when only the latest value is relevant for the client, for example a progress value or the current time: keep the PendingJavaScriptResult and cancel it before scheduling the next update (which is what Flow itself does for the page title), or set an element property instead, since only the last value of a property is sent.

  • UI.getLastUpdateSentTimestamp() exposes when the pending updates of a UI were last purged into a response for the client, stamped in UIInternals.dumpPendingJavaScriptInvocations(). A background task that updates a UI at an interval can compare it against the current time and stop scheduling updates while nothing is reaching the client — which works for polling as well as for push, and does not require the application to hold the PendingJavaScriptResult of updates it never scheduled itself (a Binder reading a bean, for instance). It is initialized when the UI is created, so a UI that has never sent anything reports its creation time rather than 0. The javadoc is explicit that this says the updates were written towards the client, not that the client received them; the warning message points to the method as well.

  • Configuration. The new pendingJavaScriptInvocationsWarningThreshold init parameter changes the threshold, and 0 disables the warning. Counts are only inspected at every 100th invocation to keep the scheduling path to a single modulo, so a configured threshold is in practice rounded up to a multiple of 100.

API

Added: UI.getLastUpdateSentTimestamp(), UIInternals.getLastUpdateSentTimestamp() and InitParameters.PENDING_JAVASCRIPT_INVOCATIONS_WARNING_THRESHOLD. Nothing removed or changed. PendingJavaScriptInvocationUtil and the counter accessors on UIInternals are package private, and StateNode is untouched. The added memory is one int per UI, plus one reference on each PendingJavaScriptInvocation that is waiting to be sent.

Verification

mvn -pl flow-server test passes (5056 tests, 12 of them new). The new tests cover the warning tiers (first warning at the threshold, then only tenfold, and 0 disabling it), the counter over the scheduled → sent and scheduled → canceled paths including a double cancel, an invocation for a detached owner being counted in the current UI and one with no UI at all not being counted, the contents of the message for a detached, an invisible and a connection-less owner, truncation of long expressions to a single line, executeJs on a detached element, and the purge timestamp being updated by a purge but not by scheduling an invocation. The end-to-end behaviour was checked separately by scheduling 1000 executeJs calls in a UI without push and confirming that exactly one warning is logged, with the text quoted above.

UIInternals.setTitle built its own JavaScriptInvocation and
PendingJavaScriptInvocation and queued them directly, duplicating what
Page.executeJs already does with the same owner node. Delegate to
Page.executeJs instead, so that PendingJavaScriptInvocation instances are
only created in Element and Page. This keeps the behavior unchanged and
leaves a single place to hook bookkeeping of scheduled invocations into.
Invocations scheduled with executeJs are retained in memory until they are
sent to the browser, which means that an application that keeps scheduling
invocations that are never delivered runs out of memory. This is invisible
to the developer until a heap dump is analyzed, since neither the
invocations parked in the before-client-response queue of a state node nor
the ones waiting in UIInternals are reported anywhere.

Count the invocations that have been scheduled for a state node without
being sent, and log a warning when the count reaches 1000 for a single
owner, repeating it whenever the count grows tenfold. The warning names the
owner component and the most recently scheduled expression, tells whether
the owner is detached or invisible and whether the UI has an open push
connection, and describes how to schedule updates where only the latest
value matters, either by canceling the previous PendingJavaScriptResult or
by setting an element property instead. Debug logging adds the call site.

The threshold can be changed with the new
pendingJavaScriptInvocationsWarningThreshold configuration property, and 0
disables the warning. Counts are only inspected at every 100th invocation
to keep the scheduling path cheap, so the configured threshold is in
practice rounded up to a multiple of 100.
A background task that updates a UI at a regular interval has no supported
way of noticing that its updates are not reaching the client and only
accumulate in memory. Checking the push connection does not cover UIs that
rely on polling, and the invocations are usually scheduled indirectly, for
instance by a Binder, so the application does not hold the
PendingJavaScriptResult that would tell whether anything was sent.

Record the time when the queue of pending updates is purged into a response
for the client and expose it as UI.getLastUpdateSentTimestamp(), so that a
background task can compare it against the current time and stop scheduling
updates while nothing is being written to the client. The timestamp is
initialized when the UI is created, so a UI that has never sent anything
reports its creation time instead of zero.

The warning about undelivered invocations now points to this method as the
way for an application to avoid piling up unsent updates.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 373 files  + 1   1 374 suites  +1   1h 29m 15s ⏱️ + 2m 14s
10 462 tests +17  10 395 ✅ +17  67 💤 ±0  0 ❌ ±0 
10 781 runs  +17  10 713 ✅ +17  68 💤 ±0  0 ❌ ±0 

Results for commit e0289cf. ± Comparison against base commit 1f26a22.

♻️ This comment has been updated with latest results.

A running application has far more state nodes than UIs, so the field that
counted the invocations scheduled for a node multiplied into a noticeable
amount of resident memory even for the vast majority of nodes that never
schedule a single JavaScript invocation.

Keep the count in UIInternals instead, where a single field per UI is
insignificant, and let the pending invocation remember which UI counted it so
that it decrements the same counter when it is sent or canceled. The counter
is resolved from the owner node when it is attached, and otherwise from the
current UI, which keeps invocations scheduled for a detached component
counted in the UI whose code scheduled them.

Since the threshold is now reached per UI rather than per component, the
warning reports the number for the UI and names the component the most
recently scheduled invocation belongs to. Diffuse accumulation across many
components is now covered as well.

The counter accessors are package private, so this also removes the public
methods that were added to StateNode.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
72.4% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants