1// Keeps a tab that has been away from coming back to a page that looks fine and is dead. 2// 3// Blazor Server keeps the page's state on the server, in a circuit. Once the server drops one, 4// Blazor CANNOT tell you: a reconnect after the circuit is gone reports success exactly like a 5// real rejoin, the reconnect notice disappears, and the page sits there looking healthy while 6// nothing on it reaches the server and every action fails. Verified against a build with a short 7// retention: reconnecting after the circuit had gone reported a successful reconnect both times, 8// and a button that had worked moments earlier did nothing afterwards. 9// 10// That is what a tab left open overnight hits. A hidden tab also has its timers throttled to about 11// once a minute, so it can fail to notice the connection died at all until someone looks at it - 12// which means the reconnect signal is not reliable here either. How long the tab was away is 13// independent of all of it, so that is what this measures. 14// 15// Only the away case is handled. A tab that stays visible is not throttled, keeps pinging, and 16// keeps its circuit; if it loses one anyway the outage is real, Blazor's own reconnect UI is on 17// screen the whole time, and the user can see it. 18// 19// CIRCUIT_LIFETIME_MS must stay under DisconnectedCircuitRetentionPeriod in Program.cs. The two 20// move together - raising retention without raising this just makes the reload happen earlier than 21// it needs to; lowering retention below this brings the dead-page bug back. 22(function () { 23 'use strict'; 24 25 const CIRCUIT_LIFETIME_MS = 13 * 60 * 1000; // Program.cs holds circuits for 15 minutes 26 27 let hiddenSince = null; 28 29 // Date.now() is wall clock, so this stays correct across a suspended or sleeping machine. 30 function reloadIfAwayTooLong() { 31 const awayFor = hiddenSince === null ? 0 : Date.now() - hiddenSince; 32 hiddenSince = null; 33 if (awayFor > CIRCUIT_LIFETIME_MS) { 34 window.location.reload(); 35 } 36 } 37 38 document.addEventListener('visibilitychange', function () { 39 if (document.visibilityState === 'hidden') { 40 hiddenSince = Date.now(); 41 return; 42 } 43 reloadIfAwayTooLong(); 44 }); 45 46 // The back/forward cache freezes the page rather than hiding it: no timer of ours runs from 47 // 'pagehide' until 'pageshow', so that is the one chance to record when the page actually went, 48 // and a restore does not reliably arrive through visibilitychange. Same question, asked where 49 // it is certain to be asked. 50 window.addEventListener('pagehide', function (event) { 51 if (event.persisted && hiddenSince === null) hiddenSince = Date.now(); 52 }); 53 54 window.addEventListener('pageshow', function (event) { 55 if (event.persisted) reloadIfAwayTooLong(); 56 }); 57 58 // -- Two things Blazor does on its own, both of which kill a restored page -------------- 59 // 60 // 1. Blazor registers a 'pagehide' listener that beacons POST _blazor/disconnect, and the 61 // server TERMINATES the circuit on that beacon - terminated, not retained, so the 62 // retention period never applies to it. pagehide also fires when the browser freezes the 63 // page into the back/forward cache, which is not the page going away: it can come back, 64 // DOM intact, holding a circuit id that was destroyed on the way out. 65 // 66 // 2. .NET 10 gave the reconnect loop a second chance: when rejoining fails Blazor calls 67 // Blazor.resumeCircuit(), which asks the server to build a NEW circuit out of state the 68 // browser was handed when the old one was PAUSED. Nothing here pauses a circuit, so there 69 // is no such state and the resume comes back empty - and it reports success, so the 70 // reconnect notice hides over a page whose DOM belongs to a circuit that no longer 71 // exists. Every click then throws 'DispatchEventAsync' on the server and the page only 72 // scrolls. On .NET 9 the same failed rejoin reloaded the page. 73 // 74 // So: beacon only on a real unload, and refuse the resume, which puts a failed rejoin back on 75 // Blazor's own 'rejected' path - a reload. 76 // 77 // Blazor sets both of these up when the circuit starts, which is after this file runs, so wait 78 // for it rather than assume. It exposes the listener it registered as Blazor.disconnect, so 79 // this replaces that listener rather than racing it - a second listener could not stop the 80 // first from firing. 81 // 82 // No deadline on the wait. A statically rendered page starts no circuit at all, and enhanced 83 // navigation from one to an interactive page does not reload the document, so the circuit can 84 // start minutes after this file ran - a wait that had given up by then would leave that page 85 // with neither guard. The wait costs two property reads twice a second and stops the moment 86 // there is a circuit to guard. 87 const POLL_MS = 500; 88 89 const waitForBlazor = setInterval(function () { 90 if (!window.Blazor || typeof window.Blazor.disconnect !== 'function') return; 91 clearInterval(waitForBlazor); 92 93 const sendDisconnectBeacon = window.Blazor.disconnect; 94 window.removeEventListener('pagehide', sendDisconnectBeacon); 95 window.addEventListener('pagehide', function (event) { 96 if (!event.persisted) sendDisconnectBeacon(); 97 }); 98 99 const handler = window.Blazor.defaultReconnectionHandler; 100 if (handler && '_resumeCallback' in handler) { 101 handler._resumeCallback = async function () { return false; }; 102 } else { 103 // Renamed by a framework update - say so rather than quietly letting the resume back in. 104 console.warn('circuitStaleness: could not disable circuit resume; a failed rejoin may leave the page unresponsive.'); 105 } 106 }, POLL_MS); 107})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.