Skip to content

Commit 0fc2823

Browse files
committed
fix(map): pause AIS push timer while tab is hidden
Helm: > Chrome seems to put the tab into sleep when its in background. > Then the page is reported as unresponsive when opened again. A long-hidden tab queued 3 s aisTimer ticks under Chrome's background throttle. On resume the renderer drained the backlog plus a 200+ vessel CPA / COLREGS rebuild back-to-back on the single WASM thread, blocked past 5 s, and Chrome painted "page unresponsive". visibility.js gains attachVisibilityEdges + detachVisibilityEdges: one subscriber at a time with both-edges callbacks, fires the hidden edge synchronously if the tab is already hidden at attach time (covers helm navigating to /map from another tab and switching away before the page finishes mounting). Map.razor pauses aisTimer on the hidden edge via Change(Infinite), re-arms on the visible edge with AisPushInitialDelayMs so Blazor's layout pass settles before the JSInterop push hits the same frame. Detach + dispose mirror the deadmanModule pattern in DisposeAsync. WebSocket stays connected throughout so anchor / CPA / MOB alarm rules keep evaluating from live SK data; only the visual push that a hidden chart can't render is suppressed.
1 parent 75a00f2 commit 0fc2823

2 files changed

Lines changed: 148 additions & 9 deletions

File tree

OnaPlotter/Components/Pages/Map.razor

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,17 @@
593593
// reuse this without needing to re-attach, and so dispose can detach
594594
// BEFORE disposing the main module.
595595
private IJSObjectReference? deadmanModule;
596+
// Module handle for the visibilitychange-edges listener (see
597+
// wwwroot/js/visibility.js). Drives aisTimer pause/resume so a
598+
// long-hidden tab doesn't queue a fire-storm of pushes that blocks
599+
// the renderer past Chrome's 5 s "page unresponsive" threshold on
600+
// wake.
601+
private IJSObjectReference? visibilityEdgesModule;
602+
// Tracks whether the tab is currently visible to the helm. Updated
603+
// by OnPageHidden / OnPageVisible JSInvokables; gates aisTimer's
604+
// Change calls so a no-op transition (already hidden, still hidden)
605+
// doesn't churn the timer.
606+
private bool _pageIsVisible = true;
596607
private DotNetObjectReference<Map>? dotNetRef;
597608
private bool follow;
598609
private bool nightMode;
@@ -682,6 +693,40 @@
682693
colregsHelpOpen = true;
683694
StateHasChanged();
684695
}
696+
697+
/// <summary>Fired from wwwroot/js/visibility.js when the tab goes
698+
/// hidden. Pauses the AIS push timer so a long-hidden tab doesn't
699+
/// queue backlogged ticks that drain back-to-back on resume and
700+
/// trip Chrome's "page unresponsive" dialog. Idempotent: a no-op
701+
/// when already hidden.</summary>
702+
[JSInvokable]
703+
public void OnPageHidden()
704+
{
705+
if (!_pageIsVisible) return;
706+
_pageIsVisible = false;
707+
// ObjectDisposedException can race with DisposeAsync: the
708+
// detach call in DisposeAsync runs before aisTimer.DisposeAsync,
709+
// but a visibilitychange that JS already dispatched can still
710+
// be in flight on the WASM scheduler. Swallow the race; the
711+
// page is going away anyway.
712+
try { aisTimer?.Change(Timeout.Infinite, Timeout.Infinite); }
713+
catch (ObjectDisposedException) { }
714+
}
715+
716+
/// <summary>Fired from wwwroot/js/visibility.js when the tab
717+
/// becomes visible. Re-arms the AIS push timer with the standard
718+
/// initial-delay so the first push lands ~1 s after wake-up,
719+
/// giving Blazor's render scheduler room to settle the layout pass
720+
/// before the JSInterop payload hits the same frame. Idempotent.
721+
/// </summary>
722+
[JSInvokable]
723+
public void OnPageVisible()
724+
{
725+
if (_pageIsVisible) return;
726+
_pageIsVisible = true;
727+
try { aisTimer?.Change(AisPushInitialDelayMs, AisPushIntervalMs); }
728+
catch (ObjectDisposedException) { }
729+
}
685730
// Per-tick frame-building state (previous position, laylines
686731
// throttle counter, course-line-drawn flag) lives in this builder
687732
// so the decision tree in HandleDataChanged stays small and the
@@ -1479,6 +1524,32 @@
14791524
deadmanModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/deadman.js");
14801525
await deadmanModule.InvokeVoidAsync("attachDeadman", dotNetRef, nameof(OnDeadmanTouch));
14811526

1527+
// Pause the AIS push timer when the tab goes hidden. The
1528+
// WebSocket stays connected so anchor / CPA / MOB alarms keep
1529+
// evaluating from live SK data, but the 3 s push (200+ vessel
1530+
// CPA/COLREGS compute + JSInterop payload) is wasted work on
1531+
// an invisible chart. Without this, a long-hidden tab queues
1532+
// backlogged ticks under Chrome's background throttle and on
1533+
// resume drains them back-to-back on the single WASM thread,
1534+
// blocking the renderer past 5 s and tripping the "page
1535+
// unresponsive" dialog.
1536+
try
1537+
{
1538+
visibilityEdgesModule = await JS.InvokeAsync<IJSObjectReference>(
1539+
"import", "./js/visibility.js");
1540+
await visibilityEdgesModule.InvokeVoidAsync(
1541+
"attachVisibilityEdges", dotNetRef,
1542+
nameof(OnPageVisible), nameof(OnPageHidden));
1543+
}
1544+
catch (JSDisconnectedException) { }
1545+
catch (Exception ex)
1546+
{
1547+
// Best-effort: a missing module file or a JSDisconnected at
1548+
// boot leaves the page running its previous wake-up shape
1549+
// rather than failing the whole Map mount.
1550+
Console.Error.WriteLine($"[map] visibility-edges hook failed: {ex.Message}");
1551+
}
1552+
14821553
// Hand the loaded module to the radar overlay host so the
14831554
// manager can drive it. Wired here (not in DI) because the
14841555
// module reference only exists after the import resolves.
@@ -4936,6 +5007,19 @@
49365007
}
49375008
catch (JSDisconnectedException) { }
49385009
}
5010+
// Same shape as the deadman teardown above: detach the JS-side
5011+
// visibility-edges listener BEFORE the dotNetRef is disposed so
5012+
// a visibilitychange firing mid-dispose doesn't try to invoke
5013+
// OnPageHidden/Visible on a torn-down proxy.
5014+
if (visibilityEdgesModule is not null)
5015+
{
5016+
try
5017+
{
5018+
await visibilityEdgesModule.InvokeVoidAsync("detachVisibilityEdges");
5019+
await visibilityEdgesModule.DisposeAsync();
5020+
}
5021+
catch (JSDisconnectedException) { }
5022+
}
49395023
if (module is not null)
49405024
{
49415025
// Bare interop: MarkDisposed above already gates the typed

OnaPlotter/wwwroot/js/visibility.js

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
// Tab-visibility hook used by MainLayout's auth chip: when the
2-
// helm switches away to the SK admin tab to log in and switches
3-
// back, we want to re-probe /skServer/loginStatus immediately so
4-
// the "Not logged in" chip clears without waiting up to 5 minutes
5-
// for the next poll. Without this, the chip is a UX dead-end -
6-
// the helm fixed the underlying problem but the warning insists
7-
// otherwise.
1+
// Tab-visibility hooks.
82
//
9-
// Browser support: visibilitychange is universal in modern
10-
// browsers (every browser shipping in 2024+). No polyfill needed.
3+
// `attachVisibilityListener` is the one-shot visible-edge hook used by
4+
// MainLayout's auth chip: when the helm switches away to the SK admin
5+
// tab to log in and switches back, we re-probe /skServer/loginStatus
6+
// immediately so the "Not logged in" chip clears without waiting up to
7+
// 5 minutes for the next poll.
8+
//
9+
// `attachVisibilityEdges` is the both-edges hook used by Map.razor to
10+
// pause the AIS push timer while hidden. A long-hidden tab otherwise
11+
// queues 3 s ticks at heavy throttle; on resume Chrome unleashes the
12+
// backlog plus a 200+ vessel CPA/COLREGS compute through the single
13+
// WASM thread, blocks the renderer past 5 s, and trips Chrome's "page
14+
// unresponsive" dialog.
15+
//
16+
// Browser support: visibilitychange is universal in modern browsers
17+
// (every browser shipping in 2024+). No polyfill needed.
1118

1219
let _attached = false;
1320

@@ -35,3 +42,51 @@ export function attachVisibilityListener(dotNetRef, methodName) {
3542
dotNetRef.invokeMethodAsync(methodName).catch(() => {});
3643
});
3744
}
45+
46+
// State for attachVisibilityEdges. Separate from the auth-chip flag
47+
// above because the page-edges hook follows Map.razor's lifecycle and
48+
// needs detach on nav-away, while the auth chip listener stays for
49+
// the life of the app (MainLayout never unmounts).
50+
let _edgesListener = null;
51+
52+
/**
53+
* Register a document listener that fires one of two [JSInvokable]
54+
* methods on every visibility transition, depending on direction.
55+
* One subscriber at a time - calling attach again replaces the
56+
* previous handler so an OnAfterRenderAsync re-run (re-mount on
57+
* browser back / forward) doesn't stack listeners.
58+
*
59+
* Also fires the hidden-edge callback synchronously if the tab is
60+
* already hidden at attach time - covers the case where the helm
61+
* navigated to /map from another tab and switched away before the
62+
* page finished mounting; without this, the AIS timer the page just
63+
* armed would tick uselessly until the helm returns and the visible-
64+
* edge fires.
65+
*
66+
* @param dotNetRef DotNetObjectReference<Map> from C# side
67+
* @param onVisibleMethod [JSInvokable] name to invoke on visible-edge
68+
* @param onHiddenMethod [JSInvokable] name to invoke on hidden-edge
69+
*/
70+
export function attachVisibilityEdges(dotNetRef, onVisibleMethod, onHiddenMethod) {
71+
detachVisibilityEdges();
72+
_edgesListener = () => {
73+
const method = document.visibilityState === 'visible'
74+
? onVisibleMethod : onHiddenMethod;
75+
if (!method || !dotNetRef) return;
76+
dotNetRef.invokeMethodAsync(method).catch(() => {});
77+
};
78+
document.addEventListener('visibilitychange', _edgesListener);
79+
if (document.visibilityState === 'hidden' && onHiddenMethod && dotNetRef) {
80+
dotNetRef.invokeMethodAsync(onHiddenMethod).catch(() => {});
81+
}
82+
}
83+
84+
/**
85+
* Tear down the listener set up by attachVisibilityEdges. Safe to
86+
* call when no listener is attached (idempotent).
87+
*/
88+
export function detachVisibilityEdges() {
89+
if (!_edgesListener) return;
90+
document.removeEventListener('visibilitychange', _edgesListener);
91+
_edgesListener = null;
92+
}

0 commit comments

Comments
 (0)