@burdenoff/microfe-planmagnet 2026.1001.7 → 2026.1001.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,8 +26,20 @@
26
26
  * configured — see `PlanMagnetProvider`) always take this path; that is expected, not a defect, and is why a
27
27
  * subscription error here is never surfaced as a page error.
28
28
  */
29
- export type LiveRefreshKind = 'workItem' | 'project' | 'sprint';
29
+ export type LiveRefreshKind = 'workItem' | 'project' | 'sprint' | 'roadmap';
30
30
  export type LiveRefreshMode = 'connecting' | 'live' | 'polling';
31
+ export interface LiveEvent {
32
+ kind: string;
33
+ id: string;
34
+ workspaceId: string;
35
+ projectId?: string | null;
36
+ /** `roadmap`-kind events only (`PlanmagnetRoadmapEvent`) — the roadmap this change belongs to. Equal to `id`
37
+ * when the change is to the roadmap itself (as opposed to one of its items). */
38
+ roadmapId?: string | null;
39
+ changedFields: string[];
40
+ actorId: string;
41
+ at: string;
42
+ }
31
43
  export interface UseLiveRefreshOptions {
32
44
  /** Which event stream to subscribe to — see `PlanmagnetOn{Project,Sprint,WorkItem}Updated` in the schema. */
33
45
  kind: LiveRefreshKind;
@@ -41,6 +53,9 @@ export interface UseLiveRefreshOptions {
41
53
  /** Narrows a `workItem`-kind stream to one item. Unused by every current caller (the board/dashboards want
42
54
  * every item's events for their scope) but supported for a future single-item page. */
43
55
  workItemId?: string | null;
56
+ /** Required for `kind: 'roadmap'` — `planmagnetOnRoadmapUpdated` is always scoped to exactly one roadmap
57
+ * (PRM-11), there is no workspace-wide roadmap stream. */
58
+ roadmapId?: string | null;
44
59
  /** Apollo operation names this page's queries were declared with — refetched by name
45
60
  * (`client.refetchQueries({ include })`) on a (debounced, optionally throttled) event. Never the literal
46
61
  * query document, and never the event's own payload used as data. */
@@ -60,6 +75,22 @@ export interface UseLiveRefreshOptions {
60
75
  * team-scoped sprint (no `projectId` at all) rather than falling back to a workspace-wide stream. Default
61
76
  * `true` (no extra gate). */
62
77
  enabled?: boolean;
78
+ /** Called with the raw event whenever one arrives — ALONGSIDE, never instead of, the debounced `refetch` this
79
+ * hook already schedules. For a page that needs to react to the event itself, not just "something changed,
80
+ * re-read" — e.g. the roadmap detail page (PRM-11) telling a DELETED event on the roadmap itself
81
+ * (`event.id === event.roadmapId`) apart from one on an item, to show "This roadmap was deleted" instead of
82
+ * racing a refetch against a record that is already gone. This never hands the caller anything to render
83
+ * directly from: `event` is still the same thin notification `useLiveRefresh` never applies as data itself —
84
+ * only `kind`/`id`/`roadmapId` are meaningful, the record always still comes from a query. */
85
+ onEvent?: (event: LiveEvent) => void;
86
+ /** Holds back a pending refetch while `true` — for a page with its own uninterruptible local gesture in
87
+ * progress that a refetch-driven re-render would break (PRM-11: the Now/Next/Later board, for exactly as long
88
+ * as one of its cards is mid-drag, so a live-triggered refetch never replaces the list — and the native HTML5
89
+ * drag source with it — out from under an in-flight drag). An event arriving while paused is never dropped:
90
+ * it still runs through the normal debounce/floor; only the moment the resulting refetch is allowed to
91
+ * actually fire is held, and it fires once, immediately (not re-debounced), the moment this goes back to
92
+ * `false`. Default `false`. */
93
+ paused?: boolean;
63
94
  }
64
95
  export interface UseLiveRefreshResult {
65
96
  /** `null` while a required id (e.g. a sprint page before `sprintId` resolves) hasn't loaded — there is
@@ -72,5 +103,5 @@ export interface UseLiveRefreshResult {
72
103
  * "Live" while one of its own streams is actually degraded. `null` (nothing subscribed yet) only when every
73
104
  * input is `null`. */
74
105
  export declare function combineLiveRefreshModes(...modes: ReadonlyArray<LiveRefreshMode | null>): LiveRefreshMode | null;
75
- export declare function useLiveRefresh({ kind, projectId, sprintId, workItemId, queries, debounceMs, minIntervalMs, connectTimeoutMs, pollIntervalMs, enabled, }: UseLiveRefreshOptions): UseLiveRefreshResult;
106
+ export declare function useLiveRefresh({ kind, projectId, sprintId, workItemId, roadmapId, queries, debounceMs, minIntervalMs, connectTimeoutMs, pollIntervalMs, enabled, onEvent, paused, }: UseLiveRefreshOptions): UseLiveRefreshResult;
76
107
  //# sourceMappingURL=useLiveRefresh.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useLiveRefresh.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/hooks/useLiveRefresh.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;AAChE,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,MAAM,GAAG,SAAS,CAAC;AA6DhE,MAAM,WAAW,qBAAqB;IACpC,6GAA6G;IAC7G,IAAI,EAAE,eAAe,CAAC;IACtB;;+EAE2E;IAC3E,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;kHAC8G;IAC9G,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;2FACuF;IACvF,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;;yEAEqE;IACrE,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;mHAE+G;IAC/G,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4GAA4G;IAC5G,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;iCAG6B;IAC7B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC;8GAC0G;IAC1G,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;CAC9B;AAED;;;;sBAIsB;AACtB,wBAAgB,uBAAuB,CACrC,GAAG,KAAK,EAAE,aAAa,CAAC,eAAe,GAAG,IAAI,CAAC,GAC9C,eAAe,GAAG,IAAI,CAMxB;AA8DD,wBAAgB,cAAc,CAAC,EAC7B,IAAI,EACJ,SAAS,EACT,QAAQ,EACR,UAAU,EACV,OAAO,EACP,UAAgB,EAChB,aAAa,EACb,gBAAuB,EACvB,cAAsB,EACtB,OAAc,GACf,EAAE,qBAAqB,GAAG,oBAAoB,CAqI9C"}
1
+ {"version":3,"file":"useLiveRefresh.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/hooks/useLiveRefresh.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAC5E,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,MAAM,GAAG,SAAS,CAAC;AAEhE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;oFACgF;IAChF,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACZ;AAqED,MAAM,WAAW,qBAAqB;IACpC,6GAA6G;IAC7G,IAAI,EAAE,eAAe,CAAC;IACtB;;+EAE2E;IAC3E,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;kHAC8G;IAC9G,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;2FACuF;IACvF,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;8DAC0D;IAC1D,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;yEAEqE;IACrE,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;mHAE+G;IAC/G,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4GAA4G;IAC5G,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;iCAG6B;IAC7B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;kGAM8F;IAC9F,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC;;;;;;mCAM+B;IAC/B,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC;8GAC0G;IAC1G,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;CAC9B;AAED;;;;sBAIsB;AACtB,wBAAgB,uBAAuB,CACrC,GAAG,KAAK,EAAE,aAAa,CAAC,eAAe,GAAG,IAAI,CAAC,GAC9C,eAAe,GAAG,IAAI,CAMxB;AA+DD,wBAAgB,cAAc,CAAC,EAC7B,IAAI,EACJ,SAAS,EACT,QAAQ,EACR,UAAU,EACV,SAAS,EACT,OAAO,EACP,UAAgB,EAChB,aAAa,EACb,gBAAuB,EACvB,cAAsB,EACtB,OAAc,EACd,OAAO,EACP,MAAc,GACf,EAAE,qBAAqB,GAAG,oBAAoB,CA4K9C"}
@@ -32,6 +32,18 @@ var l = a`
32
32
  }
33
33
  }
34
34
  `, d = a`
35
+ subscription PlanmagnetLiveRoadmapUpdated($workspaceId: ID!, $roadmapId: ID!) {
36
+ planmagnetOnRoadmapUpdated(workspaceId: $workspaceId, roadmapId: $roadmapId) {
37
+ kind
38
+ id
39
+ roadmapId
40
+ workspaceId
41
+ changedFields
42
+ actorId
43
+ at
44
+ }
45
+ }
46
+ `, f = a`
35
47
  subscription PlanmagnetLiveWorkItemUpdated($workspaceId: ID!, $projectId: ID, $workItemId: ID) {
36
48
  planmagnetOnWorkItemUpdated(
37
49
  workspaceId: $workspaceId
@@ -48,15 +60,15 @@ var l = a`
48
60
  }
49
61
  }
50
62
  `;
51
- function f(...e) {
63
+ function p(...e) {
52
64
  let t = e.filter((e) => e !== null);
53
65
  return t.length === 0 ? null : t.includes("polling") ? "polling" : t.includes("connecting") ? "connecting" : "live";
54
66
  }
55
- var p = 5e3, m = 3;
56
- function h(e) {
57
- return e === "project" ? l : e === "sprint" ? u : d;
67
+ var m = 5e3, h = 3;
68
+ function g(e) {
69
+ return e === "project" ? l : e === "sprint" ? u : e === "roadmap" ? d : f;
58
70
  }
59
- function g(e, i, a) {
71
+ function _(e, i, a) {
60
72
  let o = r(null), s = r(null), c = r(0), l = r(!1), u = t(() => {
61
73
  l.current = !1, c.current = Date.now(), e();
62
74
  }, [e]), d = t(() => {
@@ -81,64 +93,81 @@ function g(e, i, a) {
81
93
  o.current && clearTimeout(o.current), s.current && clearTimeout(s.current);
82
94
  }, []), d;
83
95
  }
84
- function _({ kind: a, projectId: l, sprintId: u, workItemId: d, queries: f, debounceMs: _ = 500, minIntervalMs: v, connectTimeoutMs: y = 8e3, pollIntervalMs: b = 3e4, enabled: x = !0 }) {
85
- let { workspaceId: S } = e(), C = o(), w = !S || a === "sprint" && !u && !l || a === "project" && !l || !x, T = w ? null : `${a}:${S}:${l ?? ""}:${u ?? ""}:${d ?? ""}`, E = t(() => {
86
- c(), C.refetchQueries({ include: [...f] });
87
- }, [C, f]), D = g(E, _, v), [O, k] = i(T ? "connecting" : null), [A, j] = i(T);
88
- T !== A && (j(T), k(T ? "connecting" : null)), n(() => {
89
- if (!T) return;
96
+ function v({ kind: a, projectId: l, sprintId: u, workItemId: d, roadmapId: f, queries: p, debounceMs: v = 500, minIntervalMs: y, connectTimeoutMs: b = 8e3, pollIntervalMs: x = 3e4, enabled: S = !0, onEvent: C, paused: w = !1 }) {
97
+ let { workspaceId: T } = e(), E = o(), D = !T || a === "sprint" && !u && !l || a === "project" && !l || a === "roadmap" && !f || !S, O = D ? null : `${a}:${T}:${l ?? ""}:${u ?? ""}:${d ?? ""}:${f ?? ""}`, k = t(() => {
98
+ c(), E.refetchQueries({ include: [...p] });
99
+ }, [E, p]), A = r(w);
100
+ n(() => {
101
+ A.current = w;
102
+ }, [w]);
103
+ let j = r(!1), M = t(() => {
104
+ if (A.current) {
105
+ j.current = !0;
106
+ return;
107
+ }
108
+ k();
109
+ }, [k]);
110
+ n(() => {
111
+ !w && j.current && (j.current = !1, k());
112
+ }, [w, k]);
113
+ let N = _(M, v, y), [P, F] = i(O ? "connecting" : null), [I, L] = i(O);
114
+ O !== I && (L(O), F(O ? "connecting" : null)), n(() => {
115
+ if (!O) return;
90
116
  let e = setTimeout(() => {
91
- k((e) => e === "connecting" ? "live" : e);
92
- }, y);
117
+ F((e) => e === "connecting" ? "live" : e);
118
+ }, b);
93
119
  return () => clearTimeout(e);
94
- }, [T, y]);
95
- let M = r(0), N = r(0), P = r(null);
96
- n(() => (M.current = Date.now(), N.current = 0, () => {
97
- P.current && clearTimeout(P.current), P.current = null;
98
- }), [T]);
99
- let F = r(null), I = t(() => {
100
- if (N.current = Date.now() - M.current < p ? N.current + 1 : 0, N.current >= m) {
101
- k("polling");
120
+ }, [O, b]);
121
+ let R = r(0), z = r(0), B = r(null);
122
+ n(() => (R.current = Date.now(), z.current = 0, () => {
123
+ B.current && clearTimeout(B.current), B.current = null;
124
+ }), [O]);
125
+ let V = r(null), H = t(() => {
126
+ if (z.current = Date.now() - R.current < m ? z.current + 1 : 0, z.current >= h) {
127
+ F("polling");
102
128
  return;
103
129
  }
104
- P.current = setTimeout(() => {
105
- P.current = null, M.current = Date.now(), F.current?.();
130
+ B.current = setTimeout(() => {
131
+ B.current = null, R.current = Date.now(), V.current?.();
106
132
  }, 0);
107
- }, []), { restart: L } = s(h(a), {
133
+ }, []), { restart: U } = s(g(a), {
108
134
  variables: {
109
- workspaceId: S,
135
+ workspaceId: T,
110
136
  projectId: l ?? void 0,
111
137
  sprintId: u ?? void 0,
112
- workItemId: d ?? void 0
138
+ workItemId: d ?? void 0,
139
+ roadmapId: f ?? void 0
113
140
  },
114
- skip: w,
115
- onData: () => {
116
- k((e) => e === "connecting" ? "live" : e), D();
141
+ skip: D,
142
+ onData: ({ data: e }) => {
143
+ F((e) => e === "connecting" ? "live" : e);
144
+ let t = e.data?.planmagnetOnProjectUpdated ?? e.data?.planmagnetOnSprintUpdated ?? e.data?.planmagnetOnWorkItemUpdated ?? e.data?.planmagnetOnRoadmapUpdated;
145
+ t && C?.(t), N();
117
146
  },
118
147
  onError: (e) => {
119
148
  console.debug("[useLiveRefresh] subscription unavailable, falling back to polling:", {
120
149
  kind: a,
121
150
  message: e instanceof Error ? e.message : String(e)
122
- }), k("polling");
151
+ }), F("polling");
123
152
  },
124
- onComplete: I
153
+ onComplete: H
125
154
  });
126
155
  return n(() => {
127
- F.current = L;
128
- }, [L]), n(() => {
129
- if (w || O !== "polling") return;
156
+ V.current = U;
157
+ }, [U]), n(() => {
158
+ if (D || P !== "polling") return;
130
159
  let e = setInterval(() => {
131
- typeof document < "u" && document.visibilityState === "hidden" || E();
132
- }, b);
160
+ typeof document < "u" && document.visibilityState === "hidden" || M();
161
+ }, x);
133
162
  return () => clearInterval(e);
134
163
  }, [
135
- w,
136
- O,
137
- b,
138
- E
139
- ]), { mode: w ? null : O };
164
+ D,
165
+ P,
166
+ x,
167
+ M
168
+ ]), { mode: D ? null : P };
140
169
  }
141
170
  //#endregion
142
- export { f as combineLiveRefreshModes, _ as useLiveRefresh };
171
+ export { p as combineLiveRefreshModes, v as useLiveRefresh };
143
172
 
144
173
  //# sourceMappingURL=useLiveRefresh.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useLiveRefresh.js","names":[],"sources":["../../../src/planmagnet/hooks/useLiveRefresh.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useApolloClient, useSubscription } from '@apollo/client/react';\nimport { clearGraphqlFetchResponseCache } from '@burdenoff/fe-libs/shared/graphql';\nimport { usePlanMagnet } from '../providers/PlanMagnetProvider';\n\n/**\n * Live updates (PDM-05/11/21, ANR-23): a page subscribes to a thin, workspace-scoped event stream over the\n * gateway SSE path and re-reads its own named queries when something relevant changed — it never applies the\n * event payload as data (the payload carries identifiers and `changedFields` only, by server design, so a\n * subscriber re-reads under its own permissions instead of trusting something that bypassed RBAC and row-level\n * scoping). See `useDataSinkSubscription.ts` (microfe-bigconsole) for the sibling pattern this generalizes:\n * `skip` when the ids a subscription needs haven't loaded yet, and treat a transport/backend error as expected\n * degradation, not a page-breaking failure.\n *\n * Every event stream requires `workspaceId` server-side — a subscription whose `workspaceId` is not the\n * caller's own workspace is refused by the gateway, so this hook always reads it from `usePlanMagnet()` rather\n * than accepting it as a prop a caller could get wrong.\n *\n * Connection lifecycle. SSE has no connect acknowledgement: a refused subscription (bad variables, another\n * workspace, no auth) comes back at once as an error, while an accepted one simply stays open with nothing to\n * say until something changes. So a stream still open and error-free after `connectTimeoutMs` is treated as\n * live, and the first event proves it earlier. The server closes a stream after 60 s without events\n * (`REALTIME_SUBSCRIPTION_IDLE_TIMEOUT_MS` in the svc), which Apollo reports as completion — that is routine,\n * so the hook resubscribes rather than going quiet. A stream that keeps completing within seconds of opening is\n * not healthy and degrades to polling.\n *\n * Graceful fallback (this is the point of the hook, not an edge case): if the subscription errors, or keeps\n * closing as soon as it opens, `mode` becomes `'polling'` and a 30-second interval takes over, paused while the\n * tab is hidden (`document.visibilityState`). There is no reconnect-to-live attempt within one mount — once\n * degraded, a page stays on polling until it remounts. `standalone` dev/Storybook builds (no SSE link\n * configured — see `PlanMagnetProvider`) always take this path; that is expected, not a defect, and is why a\n * subscription error here is never surfaced as a page error.\n */\n\nexport type LiveRefreshKind = 'workItem' | 'project' | 'sprint';\nexport type LiveRefreshMode = 'connecting' | 'live' | 'polling';\n\ninterface LiveEvent {\n kind: string;\n id: string;\n workspaceId: string;\n projectId?: string | null;\n changedFields: string[];\n actorId: string;\n at: string;\n}\n\nconst ON_PROJECT_UPDATED = gql`\n subscription PlanmagnetLiveProjectUpdated($workspaceId: ID!, $projectId: ID) {\n planmagnetOnProjectUpdated(workspaceId: $workspaceId, projectId: $projectId) {\n kind\n id\n workspaceId\n changedFields\n actorId\n at\n }\n }\n`;\n\nconst ON_SPRINT_UPDATED = gql`\n subscription PlanmagnetLiveSprintUpdated($workspaceId: ID!, $sprintId: ID, $projectId: ID) {\n planmagnetOnSprintUpdated(\n workspaceId: $workspaceId\n sprintId: $sprintId\n projectId: $projectId\n ) {\n kind\n id\n projectId\n workspaceId\n changedFields\n actorId\n at\n }\n }\n`;\n\nconst ON_WORK_ITEM_UPDATED = gql`\n subscription PlanmagnetLiveWorkItemUpdated($workspaceId: ID!, $projectId: ID, $workItemId: ID) {\n planmagnetOnWorkItemUpdated(\n workspaceId: $workspaceId\n projectId: $projectId\n workItemId: $workItemId\n ) {\n kind\n id\n projectId\n workspaceId\n changedFields\n actorId\n at\n }\n }\n`;\n\nexport interface UseLiveRefreshOptions {\n /** Which event stream to subscribe to — see `PlanmagnetOn{Project,Sprint,WorkItem}Updated` in the schema. */\n kind: LiveRefreshKind;\n /** Narrows the stream server-side. Omitted on `workItem`/`project` this is a workspace-wide stream (the\n * Dashboard and role dashboards want every project's events); `sprint` needs `sprintId` to mean anything on a\n * single sprint's page, so the hook skips subscribing until it has one. */\n projectId?: string | null;\n /** For `kind: 'sprint'`, at least one of `sprintId`/`projectId` is required — one sprint's own events, or\n * every sprint in one project's. Neither means \"skip\" (every sprint workspace-wide has no current caller). */\n sprintId?: string | null;\n /** Narrows a `workItem`-kind stream to one item. Unused by every current caller (the board/dashboards want\n * every item's events for their scope) but supported for a future single-item page. */\n workItemId?: string | null;\n /** Apollo operation names this page's queries were declared with — refetched by name\n * (`client.refetchQueries({ include })`) on a (debounced, optionally throttled) event. Never the literal\n * query document, and never the event's own payload used as data. */\n queries: readonly string[];\n /** Coalesces a burst of events (e.g. a bulk update moving 20 cards) into one refetch. Default 500. */\n debounceMs?: number;\n /** A floor between refetches once debounced, for a page whose queries are expensive to re-run on every\n * change (ANR-23: the Dashboard and role dashboards, at most once per 15s). Unset: only the debounce window\n * applies. A pulse arriving inside the floor is never dropped — it runs at the floor's far edge (trailing). */\n minIntervalMs?: number;\n /** How long an open, error-free subscription stays \"Connecting…\" before it counts as live. Default 8000. */\n connectTimeoutMs?: number;\n /** Polling cadence once on the fallback path. Default 30000 (ANR-23/PDM-05: \"every 30s\"). */\n pollIntervalMs?: number;\n /** An extra gate beyond the kind's own required id(s) — e.g. a sprint page's `workItem`-kind stream (no id of\n * its own is required) should still wait for the sprint's `projectId` to resolve, and skip entirely for a\n * team-scoped sprint (no `projectId` at all) rather than falling back to a workspace-wide stream. Default\n * `true` (no extra gate). */\n enabled?: boolean;\n}\n\nexport interface UseLiveRefreshResult {\n /** `null` while a required id (e.g. a sprint page before `sprintId` resolves) hasn't loaded — there is\n * nothing to subscribe to yet, so `LiveStatus` renders nothing rather than a misleading \"Connecting…\". */\n mode: LiveRefreshMode | null;\n}\n\n/** Rolls up more than one `useLiveRefresh` result into the single indicator a page shows (e.g. the sprint\n * detail page subscribes to both sprint events and the sprint's project's work item events). Reports the least\n * favorable non-null mode present — `'polling'` beats `'connecting'` beats `'live'` — so the page never claims\n * \"Live\" while one of its own streams is actually degraded. `null` (nothing subscribed yet) only when every\n * input is `null`. */\nexport function combineLiveRefreshModes(\n ...modes: ReadonlyArray<LiveRefreshMode | null>\n): LiveRefreshMode | null {\n const present = modes.filter((mode): mode is LiveRefreshMode => mode !== null);\n if (present.length === 0) return null;\n if (present.includes('polling')) return 'polling';\n if (present.includes('connecting')) return 'connecting';\n return 'live';\n}\n\nconst RAPID_CLOSE_MS = 5000;\nconst RAPID_CLOSE_LIMIT = 3;\n\nfunction subscriptionDocumentFor(kind: LiveRefreshKind) {\n if (kind === 'project') return ON_PROJECT_UPDATED;\n if (kind === 'sprint') return ON_SPRINT_UPDATED;\n return ON_WORK_ITEM_UPDATED;\n}\n\n/** A debounced, optionally-throttled trigger: bursts inside `debounceMs` coalesce into one call; once debounced,\n * a call inside `minIntervalMs` of the last one is never dropped — it runs at the floor's far edge instead.\n * `run` is expected to be a `useCallback`-stable function (the hook below's `refetch` is, since it only depends\n * on `client` and `queries`) — accepted as a normal dependency rather than stashed in a ref, so it is never\n * read or written outside an effect/callback. */\nfunction useCoalescedTrigger(\n run: () => void,\n debounceMs: number,\n minIntervalMs: number | undefined\n) {\n const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const floorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const lastRunAt = useRef(0);\n const floorPending = useRef(false);\n\n const runNow = useCallback(() => {\n floorPending.current = false;\n lastRunAt.current = Date.now();\n run();\n }, [run]);\n\n const trigger = useCallback(() => {\n if (debounceTimer.current) clearTimeout(debounceTimer.current);\n debounceTimer.current = setTimeout(() => {\n debounceTimer.current = null;\n if (!minIntervalMs) {\n runNow();\n return;\n }\n const elapsed = Date.now() - lastRunAt.current;\n if (elapsed >= minIntervalMs) {\n runNow();\n return;\n }\n if (floorPending.current) return; // a trailing run is already scheduled for this window\n floorPending.current = true;\n floorTimer.current = setTimeout(runNow, minIntervalMs - elapsed);\n }, debounceMs);\n }, [debounceMs, minIntervalMs, runNow]);\n\n useEffect(\n () => () => {\n if (debounceTimer.current) clearTimeout(debounceTimer.current);\n if (floorTimer.current) clearTimeout(floorTimer.current);\n },\n []\n );\n\n return trigger;\n}\n\nexport function useLiveRefresh({\n kind,\n projectId,\n sprintId,\n workItemId,\n queries,\n debounceMs = 500,\n minIntervalMs,\n connectTimeoutMs = 8000,\n pollIntervalMs = 30000,\n enabled = true,\n}: UseLiveRefreshOptions): UseLiveRefreshResult {\n const { workspaceId } = usePlanMagnet();\n const client = useApolloClient();\n\n // 'sprint' means something scoped to either one sprint (a sprint's own page) or every sprint in one project (a\n // project-scoped dashboard) — it needs at least one of the two ids, never neither (that would be every sprint\n // in the whole workspace, which no current caller wants). 'project' always needs its one id.\n const requiredIdMissing =\n (kind === 'sprint' && !sprintId && !projectId) || (kind === 'project' && !projectId);\n const skip = !workspaceId || requiredIdMissing || !enabled;\n\n // Identifies what we'd subscribe to right now — `null` when there's nothing to. `queries` is deliberately not\n // part of this: which QUERIES a page refetches changing (it never does, in practice — every caller passes a\n // module-level const array) shouldn't reset a live connection, only what STREAM we're on should.\n const subscriptionKey = skip\n ? null\n : `${kind}:${workspaceId}:${projectId ?? ''}:${sprintId ?? ''}:${workItemId ?? ''}`;\n\n // fe-libs replays an identical query from its 15 s fetch-layer cache (shared/graphql/fetch.ts), and Apollo's\n // refetchQueries goes through it — so an event arriving within 15 s of the page's last read would re-read the\n // stale copy and the change would not show. Measured in prod: a card created ~12 s after the board loaded never\n // appeared. The cache is dropped first, exactly as a successful mutation does.\n const refetch = useCallback(() => {\n clearGraphqlFetchResponseCache();\n void client.refetchQueries({ include: [...queries] });\n }, [client, queries]);\n\n const trigger = useCoalescedTrigger(refetch, debounceMs, minIntervalMs);\n\n const [mode, setMode] = useState<LiveRefreshMode | null>(subscriptionKey ? 'connecting' : null);\n const [modeKey, setModeKey] = useState<string | null>(subscriptionKey);\n\n // Reset to \"connecting\" (or \"nothing to do\") the moment what we'd subscribe to changes — switching project,\n // sprint or page. This is the React-documented \"adjust state when a value changes\" pattern: a direct setState\n // call guarded by comparing against the last-seen key, during render, rather than in an effect — it lands in\n // the very render that changed, with no stale \"still connecting to the OLD target\" frame first.\n if (subscriptionKey !== modeKey) {\n setModeKey(subscriptionKey);\n setMode(subscriptionKey ? 'connecting' : null);\n }\n\n // The connect window needs a real timer; its only setState call lives inside the timeout's own callback (an\n // update \"when external state changes\"), never synchronously in the effect body. A refused subscription has\n // already errored into 'polling' by now (errors arrive at once), so a still-\"connecting\" one is open and\n // accepted — see the lifecycle note above.\n useEffect(() => {\n if (!subscriptionKey) return;\n const timer = setTimeout(() => {\n setMode((current) => (current === 'connecting' ? 'live' : current));\n }, connectTimeoutMs);\n return () => clearTimeout(timer);\n }, [subscriptionKey, connectTimeoutMs]);\n\n // Rapid-completion guard for the resubscribe below: when each stream closes within RAPID_CLOSE_MS of opening,\n // RAPID_CLOSE_LIMIT times in a row, the transport is not holding the stream open and polling takes over.\n const openedAt = useRef(0);\n const rapidCloses = useRef(0);\n const resubscribeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n useEffect(() => {\n openedAt.current = Date.now();\n rapidCloses.current = 0;\n return () => {\n if (resubscribeTimer.current) clearTimeout(resubscribeTimer.current);\n resubscribeTimer.current = null;\n };\n }, [subscriptionKey]);\n\n // The server's idle close (60 s without events), or the ALB ending a long-lived response: routine, so open a\n // fresh stream. Deferred a tick so Apollo finishes tearing down the completed one first. `restart` comes back\n // from `useSubscription` below, so it is reached through a ref kept current by an effect.\n const restartRef = useRef<(() => void) | null>(null);\n const handleComplete = useCallback(() => {\n const lived = Date.now() - openedAt.current;\n rapidCloses.current = lived < RAPID_CLOSE_MS ? rapidCloses.current + 1 : 0;\n if (rapidCloses.current >= RAPID_CLOSE_LIMIT) {\n setMode('polling');\n return;\n }\n resubscribeTimer.current = setTimeout(() => {\n resubscribeTimer.current = null;\n openedAt.current = Date.now();\n restartRef.current?.();\n }, 0);\n }, []);\n\n const { restart } = useSubscription<{\n planmagnetOnProjectUpdated?: LiveEvent;\n planmagnetOnSprintUpdated?: LiveEvent;\n planmagnetOnWorkItemUpdated?: LiveEvent;\n }>(subscriptionDocumentFor(kind), {\n variables: {\n workspaceId,\n projectId: projectId ?? undefined,\n sprintId: sprintId ?? undefined,\n workItemId: workItemId ?? undefined,\n },\n skip,\n onData: () => {\n // Proof the subscription is live: the connect-timeout above only flips a still-\"connecting\" mode, so this\n // is a no-op once already 'live', and never resurrects a mode that errored into 'polling'.\n setMode((current) => (current === 'connecting' ? 'live' : current));\n trigger();\n },\n onError: (error) => {\n // Covers both a real backend/network failure and the expected \"no SSE transport configured\" case in a\n // standalone/Storybook build (see PlanMagnetProvider) — either way the correct behavior is the same:\n // degrade to polling rather than break the page. Logged at debug level only; this is not a user-facing\n // error state.\n console.debug('[useLiveRefresh] subscription unavailable, falling back to polling:', {\n kind,\n message: error instanceof Error ? error.message : String(error),\n });\n setMode('polling');\n },\n onComplete: handleComplete,\n });\n\n useEffect(() => {\n restartRef.current = restart;\n }, [restart]);\n\n // Polling fallback: the same queries, every `pollIntervalMs`, paused while the tab is hidden. No separate\n // throttle window here — the interval itself is the pacing (30s default, already above any ANR-23 floor).\n useEffect(() => {\n if (skip || mode !== 'polling') return;\n const id = setInterval(() => {\n if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;\n refetch();\n }, pollIntervalMs);\n return () => clearInterval(id);\n }, [skip, mode, pollIntervalMs, refetch]);\n\n return { mode: skip ? null : mode };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useLiveRefresh.js","names":[],"sources":["../../../src/planmagnet/hooks/useLiveRefresh.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useApolloClient, useSubscription } from '@apollo/client/react';\nimport { clearGraphqlFetchResponseCache } from '@burdenoff/fe-libs/shared/graphql';\nimport { usePlanMagnet } from '../providers/PlanMagnetProvider';\n\n/**\n * Live updates (PDM-05/11/21, ANR-23): a page subscribes to a thin, workspace-scoped event stream over the\n * gateway SSE path and re-reads its own named queries when something relevant changed — it never applies the\n * event payload as data (the payload carries identifiers and `changedFields` only, by server design, so a\n * subscriber re-reads under its own permissions instead of trusting something that bypassed RBAC and row-level\n * scoping). See `useDataSinkSubscription.ts` (microfe-bigconsole) for the sibling pattern this generalizes:\n * `skip` when the ids a subscription needs haven't loaded yet, and treat a transport/backend error as expected\n * degradation, not a page-breaking failure.\n *\n * Every event stream requires `workspaceId` server-side — a subscription whose `workspaceId` is not the\n * caller's own workspace is refused by the gateway, so this hook always reads it from `usePlanMagnet()` rather\n * than accepting it as a prop a caller could get wrong.\n *\n * Connection lifecycle. SSE has no connect acknowledgement: a refused subscription (bad variables, another\n * workspace, no auth) comes back at once as an error, while an accepted one simply stays open with nothing to\n * say until something changes. So a stream still open and error-free after `connectTimeoutMs` is treated as\n * live, and the first event proves it earlier. The server closes a stream after 60 s without events\n * (`REALTIME_SUBSCRIPTION_IDLE_TIMEOUT_MS` in the svc), which Apollo reports as completion — that is routine,\n * so the hook resubscribes rather than going quiet. A stream that keeps completing within seconds of opening is\n * not healthy and degrades to polling.\n *\n * Graceful fallback (this is the point of the hook, not an edge case): if the subscription errors, or keeps\n * closing as soon as it opens, `mode` becomes `'polling'` and a 30-second interval takes over, paused while the\n * tab is hidden (`document.visibilityState`). There is no reconnect-to-live attempt within one mount — once\n * degraded, a page stays on polling until it remounts. `standalone` dev/Storybook builds (no SSE link\n * configured — see `PlanMagnetProvider`) always take this path; that is expected, not a defect, and is why a\n * subscription error here is never surfaced as a page error.\n */\n\nexport type LiveRefreshKind = 'workItem' | 'project' | 'sprint' | 'roadmap';\nexport type LiveRefreshMode = 'connecting' | 'live' | 'polling';\n\nexport interface LiveEvent {\n kind: string;\n id: string;\n workspaceId: string;\n projectId?: string | null;\n /** `roadmap`-kind events only (`PlanmagnetRoadmapEvent`) — the roadmap this change belongs to. Equal to `id`\n * when the change is to the roadmap itself (as opposed to one of its items). */\n roadmapId?: string | null;\n changedFields: string[];\n actorId: string;\n at: string;\n}\n\nconst ON_PROJECT_UPDATED = gql`\n subscription PlanmagnetLiveProjectUpdated($workspaceId: ID!, $projectId: ID) {\n planmagnetOnProjectUpdated(workspaceId: $workspaceId, projectId: $projectId) {\n kind\n id\n workspaceId\n changedFields\n actorId\n at\n }\n }\n`;\n\nconst ON_SPRINT_UPDATED = gql`\n subscription PlanmagnetLiveSprintUpdated($workspaceId: ID!, $sprintId: ID, $projectId: ID) {\n planmagnetOnSprintUpdated(\n workspaceId: $workspaceId\n sprintId: $sprintId\n projectId: $projectId\n ) {\n kind\n id\n projectId\n workspaceId\n changedFields\n actorId\n at\n }\n }\n`;\n\n// PRM-11: roadmap collaboration — every committed create/update/delete of the roadmap itself, and every\n// create/update/move/delete of one of its items, publishes here. `id` names whatever changed (the roadmap's own\n// id, or one of its items') — compare against `roadmapId` to tell a roadmap-level change from an item-level one,\n// same discipline `DocsDocDetailPagePage`'s `ON_DOC_UPDATED` uses for a single-entity stream.\nconst ON_ROADMAP_UPDATED = gql`\n subscription PlanmagnetLiveRoadmapUpdated($workspaceId: ID!, $roadmapId: ID!) {\n planmagnetOnRoadmapUpdated(workspaceId: $workspaceId, roadmapId: $roadmapId) {\n kind\n id\n roadmapId\n workspaceId\n changedFields\n actorId\n at\n }\n }\n`;\n\nconst ON_WORK_ITEM_UPDATED = gql`\n subscription PlanmagnetLiveWorkItemUpdated($workspaceId: ID!, $projectId: ID, $workItemId: ID) {\n planmagnetOnWorkItemUpdated(\n workspaceId: $workspaceId\n projectId: $projectId\n workItemId: $workItemId\n ) {\n kind\n id\n projectId\n workspaceId\n changedFields\n actorId\n at\n }\n }\n`;\n\nexport interface UseLiveRefreshOptions {\n /** Which event stream to subscribe to — see `PlanmagnetOn{Project,Sprint,WorkItem}Updated` in the schema. */\n kind: LiveRefreshKind;\n /** Narrows the stream server-side. Omitted on `workItem`/`project` this is a workspace-wide stream (the\n * Dashboard and role dashboards want every project's events); `sprint` needs `sprintId` to mean anything on a\n * single sprint's page, so the hook skips subscribing until it has one. */\n projectId?: string | null;\n /** For `kind: 'sprint'`, at least one of `sprintId`/`projectId` is required — one sprint's own events, or\n * every sprint in one project's. Neither means \"skip\" (every sprint workspace-wide has no current caller). */\n sprintId?: string | null;\n /** Narrows a `workItem`-kind stream to one item. Unused by every current caller (the board/dashboards want\n * every item's events for their scope) but supported for a future single-item page. */\n workItemId?: string | null;\n /** Required for `kind: 'roadmap'` — `planmagnetOnRoadmapUpdated` is always scoped to exactly one roadmap\n * (PRM-11), there is no workspace-wide roadmap stream. */\n roadmapId?: string | null;\n /** Apollo operation names this page's queries were declared with — refetched by name\n * (`client.refetchQueries({ include })`) on a (debounced, optionally throttled) event. Never the literal\n * query document, and never the event's own payload used as data. */\n queries: readonly string[];\n /** Coalesces a burst of events (e.g. a bulk update moving 20 cards) into one refetch. Default 500. */\n debounceMs?: number;\n /** A floor between refetches once debounced, for a page whose queries are expensive to re-run on every\n * change (ANR-23: the Dashboard and role dashboards, at most once per 15s). Unset: only the debounce window\n * applies. A pulse arriving inside the floor is never dropped — it runs at the floor's far edge (trailing). */\n minIntervalMs?: number;\n /** How long an open, error-free subscription stays \"Connecting…\" before it counts as live. Default 8000. */\n connectTimeoutMs?: number;\n /** Polling cadence once on the fallback path. Default 30000 (ANR-23/PDM-05: \"every 30s\"). */\n pollIntervalMs?: number;\n /** An extra gate beyond the kind's own required id(s) — e.g. a sprint page's `workItem`-kind stream (no id of\n * its own is required) should still wait for the sprint's `projectId` to resolve, and skip entirely for a\n * team-scoped sprint (no `projectId` at all) rather than falling back to a workspace-wide stream. Default\n * `true` (no extra gate). */\n enabled?: boolean;\n /** Called with the raw event whenever one arrives — ALONGSIDE, never instead of, the debounced `refetch` this\n * hook already schedules. For a page that needs to react to the event itself, not just \"something changed,\n * re-read\" — e.g. the roadmap detail page (PRM-11) telling a DELETED event on the roadmap itself\n * (`event.id === event.roadmapId`) apart from one on an item, to show \"This roadmap was deleted\" instead of\n * racing a refetch against a record that is already gone. This never hands the caller anything to render\n * directly from: `event` is still the same thin notification `useLiveRefresh` never applies as data itself —\n * only `kind`/`id`/`roadmapId` are meaningful, the record always still comes from a query. */\n onEvent?: (event: LiveEvent) => void;\n /** Holds back a pending refetch while `true` — for a page with its own uninterruptible local gesture in\n * progress that a refetch-driven re-render would break (PRM-11: the Now/Next/Later board, for exactly as long\n * as one of its cards is mid-drag, so a live-triggered refetch never replaces the list — and the native HTML5\n * drag source with it — out from under an in-flight drag). An event arriving while paused is never dropped:\n * it still runs through the normal debounce/floor; only the moment the resulting refetch is allowed to\n * actually fire is held, and it fires once, immediately (not re-debounced), the moment this goes back to\n * `false`. Default `false`. */\n paused?: boolean;\n}\n\nexport interface UseLiveRefreshResult {\n /** `null` while a required id (e.g. a sprint page before `sprintId` resolves) hasn't loaded — there is\n * nothing to subscribe to yet, so `LiveStatus` renders nothing rather than a misleading \"Connecting…\". */\n mode: LiveRefreshMode | null;\n}\n\n/** Rolls up more than one `useLiveRefresh` result into the single indicator a page shows (e.g. the sprint\n * detail page subscribes to both sprint events and the sprint's project's work item events). Reports the least\n * favorable non-null mode present — `'polling'` beats `'connecting'` beats `'live'` — so the page never claims\n * \"Live\" while one of its own streams is actually degraded. `null` (nothing subscribed yet) only when every\n * input is `null`. */\nexport function combineLiveRefreshModes(\n ...modes: ReadonlyArray<LiveRefreshMode | null>\n): LiveRefreshMode | null {\n const present = modes.filter((mode): mode is LiveRefreshMode => mode !== null);\n if (present.length === 0) return null;\n if (present.includes('polling')) return 'polling';\n if (present.includes('connecting')) return 'connecting';\n return 'live';\n}\n\nconst RAPID_CLOSE_MS = 5000;\nconst RAPID_CLOSE_LIMIT = 3;\n\nfunction subscriptionDocumentFor(kind: LiveRefreshKind) {\n if (kind === 'project') return ON_PROJECT_UPDATED;\n if (kind === 'sprint') return ON_SPRINT_UPDATED;\n if (kind === 'roadmap') return ON_ROADMAP_UPDATED;\n return ON_WORK_ITEM_UPDATED;\n}\n\n/** A debounced, optionally-throttled trigger: bursts inside `debounceMs` coalesce into one call; once debounced,\n * a call inside `minIntervalMs` of the last one is never dropped — it runs at the floor's far edge instead.\n * `run` is expected to be a `useCallback`-stable function (the hook below's `refetch` is, since it only depends\n * on `client` and `queries`) — accepted as a normal dependency rather than stashed in a ref, so it is never\n * read or written outside an effect/callback. */\nfunction useCoalescedTrigger(\n run: () => void,\n debounceMs: number,\n minIntervalMs: number | undefined\n) {\n const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const floorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const lastRunAt = useRef(0);\n const floorPending = useRef(false);\n\n const runNow = useCallback(() => {\n floorPending.current = false;\n lastRunAt.current = Date.now();\n run();\n }, [run]);\n\n const trigger = useCallback(() => {\n if (debounceTimer.current) clearTimeout(debounceTimer.current);\n debounceTimer.current = setTimeout(() => {\n debounceTimer.current = null;\n if (!minIntervalMs) {\n runNow();\n return;\n }\n const elapsed = Date.now() - lastRunAt.current;\n if (elapsed >= minIntervalMs) {\n runNow();\n return;\n }\n if (floorPending.current) return; // a trailing run is already scheduled for this window\n floorPending.current = true;\n floorTimer.current = setTimeout(runNow, minIntervalMs - elapsed);\n }, debounceMs);\n }, [debounceMs, minIntervalMs, runNow]);\n\n useEffect(\n () => () => {\n if (debounceTimer.current) clearTimeout(debounceTimer.current);\n if (floorTimer.current) clearTimeout(floorTimer.current);\n },\n []\n );\n\n return trigger;\n}\n\nexport function useLiveRefresh({\n kind,\n projectId,\n sprintId,\n workItemId,\n roadmapId,\n queries,\n debounceMs = 500,\n minIntervalMs,\n connectTimeoutMs = 8000,\n pollIntervalMs = 30000,\n enabled = true,\n onEvent,\n paused = false,\n}: UseLiveRefreshOptions): UseLiveRefreshResult {\n const { workspaceId } = usePlanMagnet();\n const client = useApolloClient();\n\n // 'sprint' means something scoped to either one sprint (a sprint's own page) or every sprint in one project (a\n // project-scoped dashboard) — it needs at least one of the two ids, never neither (that would be every sprint\n // in the whole workspace, which no current caller wants). 'project' and 'roadmap' always need their one id —\n // there is no workspace-wide stream for either.\n const requiredIdMissing =\n (kind === 'sprint' && !sprintId && !projectId) ||\n (kind === 'project' && !projectId) ||\n (kind === 'roadmap' && !roadmapId);\n const skip = !workspaceId || requiredIdMissing || !enabled;\n\n // Identifies what we'd subscribe to right now — `null` when there's nothing to. `queries` is deliberately not\n // part of this: which QUERIES a page refetches changing (it never does, in practice — every caller passes a\n // module-level const array) shouldn't reset a live connection, only what STREAM we're on should.\n const subscriptionKey = skip\n ? null\n : `${kind}:${workspaceId}:${projectId ?? ''}:${sprintId ?? ''}:${workItemId ?? ''}:${roadmapId ?? ''}`;\n\n // fe-libs replays an identical query from its 15 s fetch-layer cache (shared/graphql/fetch.ts), and Apollo's\n // refetchQueries goes through it — so an event arriving within 15 s of the page's last read would re-read the\n // stale copy and the change would not show. Measured in prod: a card created ~12 s after the board loaded never\n // appeared. The cache is dropped first, exactly as a successful mutation does.\n const refetch = useCallback(() => {\n clearGraphqlFetchResponseCache();\n void client.refetchQueries({ include: [...queries] });\n }, [client, queries]);\n\n // PRM-11: `paused` (the Now/Next/Later board, mid-drag) holds back the refetch a debounced trigger would\n // otherwise run — read from a ref since the debounce timer's own callback fires well after the render that set\n // `paused`. Nothing is lost: a trigger that lands while paused just marks `pendingWhilePausedRef` instead of\n // calling `refetch`, and the effect below runs the deferred refetch once, immediately, the moment `paused`\n // clears — never a second debounce wait on top of whatever already elapsed before the pause was lifted.\n const pausedRef = useRef(paused);\n useEffect(() => {\n pausedRef.current = paused;\n }, [paused]);\n const pendingWhilePausedRef = useRef(false);\n\n const guardedRefetch = useCallback(() => {\n if (pausedRef.current) {\n pendingWhilePausedRef.current = true;\n return;\n }\n refetch();\n }, [refetch]);\n\n useEffect(() => {\n if (!paused && pendingWhilePausedRef.current) {\n pendingWhilePausedRef.current = false;\n refetch();\n }\n }, [paused, refetch]);\n\n const trigger = useCoalescedTrigger(guardedRefetch, debounceMs, minIntervalMs);\n\n const [mode, setMode] = useState<LiveRefreshMode | null>(subscriptionKey ? 'connecting' : null);\n const [modeKey, setModeKey] = useState<string | null>(subscriptionKey);\n\n // Reset to \"connecting\" (or \"nothing to do\") the moment what we'd subscribe to changes — switching project,\n // sprint or page. This is the React-documented \"adjust state when a value changes\" pattern: a direct setState\n // call guarded by comparing against the last-seen key, during render, rather than in an effect — it lands in\n // the very render that changed, with no stale \"still connecting to the OLD target\" frame first.\n if (subscriptionKey !== modeKey) {\n setModeKey(subscriptionKey);\n setMode(subscriptionKey ? 'connecting' : null);\n }\n\n // The connect window needs a real timer; its only setState call lives inside the timeout's own callback (an\n // update \"when external state changes\"), never synchronously in the effect body. A refused subscription has\n // already errored into 'polling' by now (errors arrive at once), so a still-\"connecting\" one is open and\n // accepted — see the lifecycle note above.\n useEffect(() => {\n if (!subscriptionKey) return;\n const timer = setTimeout(() => {\n setMode((current) => (current === 'connecting' ? 'live' : current));\n }, connectTimeoutMs);\n return () => clearTimeout(timer);\n }, [subscriptionKey, connectTimeoutMs]);\n\n // Rapid-completion guard for the resubscribe below: when each stream closes within RAPID_CLOSE_MS of opening,\n // RAPID_CLOSE_LIMIT times in a row, the transport is not holding the stream open and polling takes over.\n const openedAt = useRef(0);\n const rapidCloses = useRef(0);\n const resubscribeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n useEffect(() => {\n openedAt.current = Date.now();\n rapidCloses.current = 0;\n return () => {\n if (resubscribeTimer.current) clearTimeout(resubscribeTimer.current);\n resubscribeTimer.current = null;\n };\n }, [subscriptionKey]);\n\n // The server's idle close (60 s without events), or the ALB ending a long-lived response: routine, so open a\n // fresh stream. Deferred a tick so Apollo finishes tearing down the completed one first. `restart` comes back\n // from `useSubscription` below, so it is reached through a ref kept current by an effect.\n const restartRef = useRef<(() => void) | null>(null);\n const handleComplete = useCallback(() => {\n const lived = Date.now() - openedAt.current;\n rapidCloses.current = lived < RAPID_CLOSE_MS ? rapidCloses.current + 1 : 0;\n if (rapidCloses.current >= RAPID_CLOSE_LIMIT) {\n setMode('polling');\n return;\n }\n resubscribeTimer.current = setTimeout(() => {\n resubscribeTimer.current = null;\n openedAt.current = Date.now();\n restartRef.current?.();\n }, 0);\n }, []);\n\n const { restart } = useSubscription<{\n planmagnetOnProjectUpdated?: LiveEvent;\n planmagnetOnSprintUpdated?: LiveEvent;\n planmagnetOnWorkItemUpdated?: LiveEvent;\n planmagnetOnRoadmapUpdated?: LiveEvent;\n }>(subscriptionDocumentFor(kind), {\n variables: {\n workspaceId,\n projectId: projectId ?? undefined,\n sprintId: sprintId ?? undefined,\n workItemId: workItemId ?? undefined,\n roadmapId: roadmapId ?? undefined,\n },\n skip,\n onData: ({ data: result }) => {\n // Proof the subscription is live: the connect-timeout above only flips a still-\"connecting\" mode, so this\n // is a no-op once already 'live', and never resurrects a mode that errored into 'polling'.\n setMode((current) => (current === 'connecting' ? 'live' : current));\n const event =\n result.data?.planmagnetOnProjectUpdated ??\n result.data?.planmagnetOnSprintUpdated ??\n result.data?.planmagnetOnWorkItemUpdated ??\n result.data?.planmagnetOnRoadmapUpdated;\n if (event) onEvent?.(event);\n trigger();\n },\n onError: (error) => {\n // Covers both a real backend/network failure and the expected \"no SSE transport configured\" case in a\n // standalone/Storybook build (see PlanMagnetProvider) — either way the correct behavior is the same:\n // degrade to polling rather than break the page. Logged at debug level only; this is not a user-facing\n // error state.\n console.debug('[useLiveRefresh] subscription unavailable, falling back to polling:', {\n kind,\n message: error instanceof Error ? error.message : String(error),\n });\n setMode('polling');\n },\n onComplete: handleComplete,\n });\n\n useEffect(() => {\n restartRef.current = restart;\n }, [restart]);\n\n // Polling fallback: the same queries, every `pollIntervalMs`, paused while the tab is hidden. No separate\n // throttle window here — the interval itself is the pacing (30s default, already above any ANR-23 floor).\n // Goes through `guardedRefetch`, not `refetch` directly: the `paused` gate above protects a local gesture from\n // a refetch-driven re-render regardless of which path (live event or poll tick) would have caused it.\n useEffect(() => {\n if (skip || mode !== 'polling') return;\n const id = setInterval(() => {\n if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;\n guardedRefetch();\n }, pollIntervalMs);\n return () => clearInterval(id);\n }, [skip, mode, pollIntervalMs, guardedRefetch]);\n\n return { mode: skip ? null : mode };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"ProductsRoadmapAnalyticsPagePage.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/pages/ProductsRoadmapAnalyticsPagePage.tsx"],"names":[],"mappings":"AA2CA,wBAAgB,gCAAgC,4CAiW/C"}
1
+ {"version":3,"file":"ProductsRoadmapAnalyticsPagePage.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/pages/ProductsRoadmapAnalyticsPagePage.tsx"],"names":[],"mappings":"AAoDA,wBAAgB,gCAAgC,4CAuY/C"}