PageSourceSearch

https://my.pressable.com/assets/controllers/global_activity_hub/trigger_poller_controller-f8fee82f.js

js pressable.com collected 2026-09-24 15:24:00 UTC 5,344 bytes, 129 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import { applyGlobalTriggerSummary } from "controllers/global_activity_hub/global_count";
3
4// Shorter than the 30s interval: a stalled fetch would otherwise pin `inFlight` and stop the header
5// trigger and sidebar badge updating on every page for the life of the page (CodeRabbit, #2544).
6const REQUEST_TIMEOUT_MS = 10000;
7
8// Ticks skipped after consecutive failures, doubling to this ceiling. This poller matters most for it:
9// it runs on EVERY authenticated page, and in production a 500 renders JSON for a JSON request
10// (ApplicationController's `rescue_from StandardError`), so it clears the content-type check and lands
11// on the `!response.ok` return — retried at fixed cadence for as long as the outage lasts, from every
12// open tab at once, since they all armed their timers at page load. The jitter tick breaks that
13// lockstep; the doubling stops each tab hammering a service that is already down (ENG-6288 review).
14const MAX_BACKOFF_TICKS = 8;
15
16// Keeps the header Activity Hub trigger and the sidebar unread badge live on every page (ENG-6288),
17// so a published alert lights them up — and a finished operation stops the spinner — without the
18// customer refreshing. Mounted on the trigger itself in the layout, which the layout only renders for
19// accounts the hub is enabled for, so the poll is scoped to exactly those sessions.
20//
21// Stands down on the Activity Hub page: the inbox poller there fetches the same summary alongside the
22// card list and applies it through the same helper, so running both would just double the requests.
23export default class extends Controller {
24  static values = {
25    summaryUrl: String,
26    intervalMs: { type: Number, default: 30000 },
27  };
28
29  connect() {
30    this.pollHandle = null;
31    this.inFlight = false;
32    this.abortController = null;
33    this.consecutiveFailures = 0;
34    this.skipTicks = 0;
35    this.stopped = document.querySelector('[data-controller~="global-activity-hub--inbox-poller"]') !== null;
36    this.boundVisibilityChange = this.handleVisibilityChange.bind(this);
37    document.addEventListener("visibilitychange", this.boundVisibilityChange);
38    this.startPolling();
39  }
40
41  disconnect() {
42    document.removeEventListener("visibilitychange", this.boundVisibilityChange);
43    this.stopPolling();
44    this.abortController?.abort();
45  }
46
47  handleVisibilityChange() {
48    if (document.visibilityState === "visible") this.startPolling();
49    else this.stopPolling();
50  }
51
52  startPolling() {
53    if (this.stopped || this.pollHandle) return;
54    if (document.visibilityState !== "visible") return;
55    this.pollHandle = setInterval(() => this.refresh(), this.intervalMsValue);
56  }
57
58  stopPolling() {
59    if (!this.pollHandle) return;
60    clearInterval(this.pollHandle);
61    this.pollHandle = null;
62  }
63
64  // Terminal: the session ended, or a protection layer rejected the request ahead of Rails — the
65  // Rack::Attack IP blocklist is registered per-IP with no path scoping and answers 403 everywhere.
66  // A trigger sits on every page, so a poller that kept firing on a signed-out tab would be the
67  // app's most persistent request.
68  stopPermanently() {
69    this.stopped = true;
70    this.stopPolling();
71  }
72
73  // Spent in whole ticks rather than by rescheduling the interval — see the inbox poller for the
74  // reasoning; both pollers share the shape.
75  //
76  // @return [Boolean] whether this tick is being skipped to back off from repeated failures.
77  backoffHoldsTick() {
78    if (this.skipTicks <= 0) return false;
79
80    this.skipTicks -= 1;
81    return true;
82  }
83
84  noteFailure() {
85    this.consecutiveFailures += 1;
86    const doubling = Math.min(2 ** (this.consecutiveFailures - 1), MAX_BACKOFF_TICKS);
87    this.skipTicks = doubling + Math.floor(Math.random() * 2);
88  }
89
90  noteSuccess() {
91    this.consecutiveFailures = 0;
92    this.skipTicks = 0;
93  }
94
95  async refresh() {
96    // One request at a time, same as the inbox poller: a response slower than the 30s interval could
97    // otherwise be applied after a newer one and revert the header and sidebar counts (ENG-6288 review).
98    if (this.inFlight) return;
99    if (this.backoffHoldsTick()) return;
100
101    this.inFlight = true;
102    this.abortController = new AbortController();
103    const deadline = setTimeout(() => this.abortController?.abort(), REQUEST_TIMEOUT_MS);
104    try {
105      const response = await fetch(this.summaryUrlValue, {
106        headers: { Accept: "application/json" },
107        credentials: "same-origin",
108        signal: this.abortController.signal,
109      });
110      if (response.status === 401 || response.status === 403) return this.stopPermanently();
111      if (!response.ok) return this.noteFailure();
112      // A redirect to the sign-in page answers 200 with HTML, so status alone cannot tell us the
113      // session is gone — the content type can.
114      if (!(response.headers.get("content-type") || "").includes("application/json")) {
115        return this.stopPermanently();
116      }
117
118      applyGlobalTriggerSummary(await response.json());
119      this.noteSuccess();
120    } catch (_error) {
121      // Network blip, or our own abort on timeout/disconnect — the timer stays armed and retries.
122      this.noteFailure();
123    } finally {
124      clearTimeout(deadline);
125      this.abortController = null;
126      this.inFlight = false;
127    }
128  }
129}

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.