From 644e23e0360b6169d478f6b98f91deb270760322 Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Wed, 7 Oct 2026 17:42:31 +0100 Subject: [PATCH 01/10] Keep page feedback from readers who decline cookies The feedback widget sends votes and comments to PostHog, which only loads after a reader accepts cookies. A reader who declines and then writes a comment currently leaves no trace of it. When PostHog refuses the capture, the comment is now posted to a configurable endpoint instead (an Apps Script Web App that appends it to a spreadsheet). It is sent only when PostHog refused, never alongside a successful capture, so nothing is recorded twice. The payload is the four fields the widget already sends (URL, title, vote, comment), with no identifier. The vote stays three-valued: a missing vote is sent as null, not as "not helpful". The endpoint and token come from DOCS_FEEDBACK_ENDPOINT and DOCS_FEEDBACK_TOKEN at build time. Both default to empty, which disables the fallback and leaves the widget behaving exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- docusaurus.config.ts | 13 ++++++ src/components/PageFeedback/direct.ts | 67 +++++++++++++++++++++++++++ src/components/PageFeedback/index.tsx | 44 ++++++++++++++++-- 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 src/components/PageFeedback/direct.ts diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 6dddd2c304..b1146ff0df 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -973,6 +973,19 @@ const config: Config = { // The search widget's whole view of docusaurus_tag, derived above and passed // through so SearchBar never constructs one of these strings itself. customFields: { + // RULING DF. The Apps Script Web App that receives feedback from readers + // who declined cookies -- PostHog is loaded by GTM behind the banner, so + // their comments reached nothing at all. Empty disables the fallback and + // the widget behaves exactly as it did before. + // + // Set from the environment so a redeploy of the Web App (which changes the + // URL) needs no code change, and so a fork or a preview build does not + // silently post into the production sheet. + feedbackEndpoint: process.env.DOCS_FEEDBACK_ENDPOINT ?? "", + // Obfuscation only: it ships in the client bundle. It stops drive-by + // scanners; the caps and validation on the receiving end stop everything + // else. + feedbackToken: process.env.DOCS_FEEDBACK_TOKEN ?? "", // Major typed in a query -> tag of the version a reader is actually served. versionTagByMajor, // A docs version's tag -> the API-reference tag(s) that document it, so a diff --git a/src/components/PageFeedback/direct.ts b/src/components/PageFeedback/direct.ts new file mode 100644 index 0000000000..7505a80248 --- /dev/null +++ b/src/components/PageFeedback/direct.ts @@ -0,0 +1,67 @@ +/** + * The consent-free fallback for a comment PostHog refused. + * + * WHY THIS EXISTS. The widget's events go to PostHog, which GTM loads BEHIND + * the consent banner: until a reader accepts cookies, `window.posthog` does + * not exist and `capturePostHogEvent` returns false. So a reader who declines + * and then spends two minutes writing us a paragraph leaves no trace of it + * anywhere. We were asking for written feedback and discarding the half that + * came from the people most careful about being tracked. + * + * ONLY WHEN POSTHOG REFUSED. Called after a failed capture, never beside a + * successful one, so a consenting reader's comment is not recorded twice under + * two different timestamps. + * + * WHERE IT GOES. An Apps Script Web App deployed to accept anonymous requests, + * which appends the comment to a spreadsheet. The endpoint must accept + * requests from a reader's browser without any sign-in. + * + * WHAT IT SENDS. The four fields the widget already sends to PostHog and + * nothing else. No identifier, and nothing derived from the reader. + */ + +type DirectFeedback = { + url: string; + title: string; + helpful: boolean | null; + comment: string; +}; + +/** + * `no-cors`, and what that costs. + * + * An Apps Script Web App does not answer a cross-origin preflight, so a normal + * JSON POST never leaves the browser. `text/plain` keeps the request "simple" + * and no-cors lets it through — at the price of an opaque response we cannot + * read. So this reports whether the request was DISPATCHED, not whether it + * arrived. + * + * That is the same promise `capturePostHogEvent` already makes and documents + * ("accepted, NOT that it reached the server"), and the widget's copy is + * written against it: the reader is told their note was sent, never that it + * was received. + */ +export async function sendDirectFeedback( + endpoint: string, + token: string, + payload: DirectFeedback, +): Promise { + if (!endpoint) return false; + try { + await fetch(endpoint, { + method: 'POST', + mode: 'no-cors', + // Deliberately text/plain: application/json would trigger a preflight + // the Web App cannot answer. + headers: { 'Content-Type': 'text/plain;charset=utf-8' }, + // `keepalive` so a reader who navigates away mid-send still delivers. + keepalive: true, + body: JSON.stringify({ ...payload, token }), + }); + return true; + } catch { + // Offline, blocked, or refused. The caller tells the reader plainly and + // keeps their text in the box. + return false; + } +} diff --git a/src/components/PageFeedback/index.tsx b/src/components/PageFeedback/index.tsx index 7878341576..81ccf90352 100644 --- a/src/components/PageFeedback/index.tsx +++ b/src/components/PageFeedback/index.tsx @@ -1,6 +1,8 @@ import React from 'react'; import BrowserOnly from '@docusaurus/BrowserOnly'; import { capturePostHogEvent } from '@site/src/components/SkillsCallout/analytics'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { sendDirectFeedback } from './direct'; import styles from './styles.module.css'; /** @@ -76,6 +78,20 @@ function Inner({ url, title }: PageFeedbackProps) { // Match `trailingSlash: true`, which is what $pathname carries. const path = url.endsWith('/') ? url : `${url}/`; + // CONFIGURED, NOT COMPILED IN. The Web App URL changes every time the Apps + // Script is redeployed, and an empty value simply disables the fallback -- + // `sendDirectFeedback` returns false and the widget behaves exactly as it + // did before, telling the reader their note did not send. + const { siteConfig } = useDocusaurusContext(); + const feedbackEndpoint = String( + (siteConfig.customFields as Record | undefined)?.feedbackEndpoint ?? '', + ); + // Obfuscation, and named as such. It ships in client JavaScript, so it stops + // scanners rather than people; the real limits are on the receiving end. + const feedbackToken = String( + (siteConfig.customFields as Record | undefined)?.feedbackToken ?? '', + ); + const [helpful, setHelpful] = React.useState(null); const [recorded, setRecorded] = React.useState(false); const [comment, setComment] = React.useState(''); @@ -189,7 +205,7 @@ function Inner({ url, title }: PageFeedbackProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [voteAttempt, recorded]); - const submit = () => { + const submit = async () => { const text = comment.trim(); if (!text) return; // Flush the held vote first, so the pair arrives in the right order. @@ -256,11 +272,26 @@ function Inner({ url, title }: PageFeedbackProps) { // confirm the insight uses an exact-match filter before relying on any of // this. if (heldVote.current !== null) sendVote(heldVote.current); - const delivered = capturePostHogEvent('docs_page_feedback_comment', { + let delivered = capturePostHogEvent('docs_page_feedback_comment', { ...base(), helpful: helpful === true, comment: text, }); + // ONLY WHEN POSTHOG REFUSED. GTM loads PostHog behind the consent banner, + // so a reader who declines cookies and then writes us a paragraph reached + // nothing at all. This second path takes only what the first dropped -- + // never alongside a successful capture, or a consenting reader's comment + // would be recorded twice under two timestamps. + if (!delivered) { + delivered = await sendDirectFeedback(feedbackEndpoint, feedbackToken, { + ...base(), + // THREE-VALUED here, unlike the PostHog call above, which has always + // coerced. A reader can type before clicking either thumb, and a + // missing vote sent as `false` becomes a complaint they did not make. + helpful: helpful === null ? null : helpful, + comment: text, + }); + } if (delivered) writeSession(COMMENT_KEY(path), 'sent'); const next = delivered ? 'sent' : 'undelivered'; // Exactly one channel, chosen by whether the message is new. @@ -391,10 +422,15 @@ function Inner({ url, title }: PageFeedbackProps) { } }} onKeyDown={(e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit(); + // `void`: submit is async now (the consent-free fallback + // awaits a dispatch), and an unhandled rejection from a key + // handler is invisible. It resolves rather than rejects -- + // `sendDirectFeedback` catches its own -- so this marks the + // intent rather than hiding a risk. + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void submit(); }} /> - From dda39cf445ff63cf060efe64a6d86111780870af Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Fri, 9 Oct 2026 12:58:27 +0100 Subject: [PATCH 02/10] Fall back only when PostHog is absent, and say where feedback goes The analytics helper now reports why a capture did not happen: PostHog not loaded, the reader opted out, or PostHog threw. The boolean helper other components use keeps its exact behaviour. The feedback widget posts a comment to the fallback endpoint only when PostHog is not loaded; an opted-out reader's comment is never sent anywhere. The note under the comment box now says where a comment will go before the reader sends it. When the fallback applies, it names the Google Apps Script form and spreadsheet, the page address and title, and that the request carries the browser's IP address and user agent. Submitting is guarded against a second send while one is in flight, and Send is marked disabled meanwhile. After the request settles, the current outcome is read from a ref, and nothing updates the widget if it has unmounted. Failure and success copy follow the path taken: a PostHog refusal keeps the "analytics is turned off or blocked" text; a failed fallback request says the connection failed or was blocked. Because the fallback uses no-cors and cannot see the response, its thanks says only that the note was sent. The endpoint and token are passed to the build of the main branch only. Pull-request builds get empty values, and preview builds force the fallback off in the config. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/build-docs.yml | 7 + .github/workflows/docs-preview.yml | 3 + docusaurus.config.ts | 12 +- src/components/PageFeedback/direct.ts | 31 ++- src/components/PageFeedback/index.tsx | 304 ++++++++++++++-------- src/components/SkillsCallout/analytics.ts | 82 ++++-- 6 files changed, 300 insertions(+), 139 deletions(-) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 3a0466a932..e784f58a96 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -73,8 +73,15 @@ jobs: - name: Verify product registry run: yarn verify:registry + # The feedback widget's fallback endpoint is set for the main branch only. + # Pull-request builds (whose ref is refs/pull/...) get empty values, so + # nothing built from an unmerged branch can post into the live feedback + # sheet. Empty disables the fallback; the widget still works without it. - name: Build documentation run: yarn build + env: + DOCS_FEEDBACK_ENDPOINT: ${{ github.ref == 'refs/heads/main' && vars.DOCS_FEEDBACK_ENDPOINT || '' }} + DOCS_FEEDBACK_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.DOCS_FEEDBACK_TOKEN || '' }} # Asserts what the widget actually sends to Algolia: the API reference is # reachable on the served version and absent on frozen legacy versions. diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index f891280f8a..ac044c99e1 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + # DOCS_FEEDBACK_ENDPOINT / DOCS_FEEDBACK_TOKEN are deliberately NOT set + # here: a preview must not post reader feedback into the production + # feedback sheet. preview_build also forces them off in the config. - name: Build documentation run: yarn build env: diff --git a/docusaurus.config.ts b/docusaurus.config.ts index b1146ff0df..721d1623b6 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -973,19 +973,21 @@ const config: Config = { // The search widget's whole view of docusaurus_tag, derived above and passed // through so SearchBar never constructs one of these strings itself. customFields: { - // RULING DF. The Apps Script Web App that receives feedback from readers - // who declined cookies -- PostHog is loaded by GTM behind the banner, so + // The Apps Script Web App that receives feedback from readers on whose + // page PostHog is not loaded -- it is loaded behind the cookie banner, so // their comments reached nothing at all. Empty disables the fallback and // the widget behaves exactly as it did before. // // Set from the environment so a redeploy of the Web App (which changes the // URL) needs no code change, and so a fork or a preview build does not - // silently post into the production sheet. - feedbackEndpoint: process.env.DOCS_FEEDBACK_ENDPOINT ?? "", + // silently post into the production sheet. Only the build of the main + // branch sets it (see build-docs.yml); a preview build forces it off even + // if the variable leaks into its environment. + feedbackEndpoint: isPreviewBuild ? "" : process.env.DOCS_FEEDBACK_ENDPOINT ?? "", // Obfuscation only: it ships in the client bundle. It stops drive-by // scanners; the caps and validation on the receiving end stop everything // else. - feedbackToken: process.env.DOCS_FEEDBACK_TOKEN ?? "", + feedbackToken: isPreviewBuild ? "" : process.env.DOCS_FEEDBACK_TOKEN ?? "", // Major typed in a query -> tag of the version a reader is actually served. versionTagByMajor, // A docs version's tag -> the API-reference tag(s) that document it, so a diff --git a/src/components/PageFeedback/direct.ts b/src/components/PageFeedback/direct.ts index 7505a80248..685e734ca3 100644 --- a/src/components/PageFeedback/direct.ts +++ b/src/components/PageFeedback/direct.ts @@ -1,23 +1,26 @@ /** - * The consent-free fallback for a comment PostHog refused. + * The fallback for a comment PostHog could not take because it is not loaded. * - * WHY THIS EXISTS. The widget's events go to PostHog, which GTM loads BEHIND + * WHY THIS EXISTS. The widget's events go to PostHog, which is loaded BEHIND * the consent banner: until a reader accepts cookies, `window.posthog` does - * not exist and `capturePostHogEvent` returns false. So a reader who declines + * not exist and the capture reports `not-loaded`. So a reader who declines * and then spends two minutes writing us a paragraph leaves no trace of it - * anywhere. We were asking for written feedback and discarding the half that - * came from the people most careful about being tracked. + * anywhere. * - * ONLY WHEN POSTHOG REFUSED. Called after a failed capture, never beside a - * successful one, so a consenting reader's comment is not recorded twice under - * two different timestamps. + * ONLY WHEN POSTHOG IS NOT LOADED. Never beside a successful capture, so a + * consenting reader's comment is not recorded twice under two timestamps; and + * never when the reader has explicitly opted out of capturing in PostHog (or + * when PostHog threw and that cannot be ruled out). An opt-out is a refusal, + * not a delivery problem to route around. * * WHERE IT GOES. An Apps Script Web App deployed to accept anonymous requests, * which appends the comment to a spreadsheet. The endpoint must accept - * requests from a reader's browser without any sign-in. + * requests from a reader's browser without any sign-in. The widget's note + * tells the reader this before they send, whenever this path applies. * * WHAT IT SENDS. The four fields the widget already sends to PostHog and - * nothing else. No identifier, and nothing derived from the reader. + * nothing else in the body. Like any web request, it also carries the + * browser's IP address and user agent to the receiving server. */ type DirectFeedback = { @@ -36,10 +39,10 @@ type DirectFeedback = { * read. So this reports whether the request was DISPATCHED, not whether it * arrived. * - * That is the same promise `capturePostHogEvent` already makes and documents - * ("accepted, NOT that it reached the server"), and the widget's copy is - * written against it: the reader is told their note was sent, never that it - * was received. + * An Apps Script that answers with an error, or was redeployed to a new URL, + * still resolves here. So on this path the widget's copy says only that the + * note was sent ("Thanks — your note was sent."), never that it reached the + * team. */ export async function sendDirectFeedback( endpoint: string, diff --git a/src/components/PageFeedback/index.tsx b/src/components/PageFeedback/index.tsx index 81ccf90352..3f278a419c 100644 --- a/src/components/PageFeedback/index.tsx +++ b/src/components/PageFeedback/index.tsx @@ -1,6 +1,10 @@ import React from 'react'; import BrowserOnly from '@docusaurus/BrowserOnly'; -import { capturePostHogEvent } from '@site/src/components/SkillsCallout/analytics'; +import { + capturePostHogEvent, + capturePostHogEventWithResult, + postHogStatus, +} from '@site/src/components/SkillsCallout/analytics'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { sendDirectFeedback } from './direct'; import styles from './styles.module.css'; @@ -31,6 +35,10 @@ import styles from './styles.module.css'; * of thanking them for something that was dropped, and every further * attempt is announced as well as focused, because focusing an element that * already has focus re-reads nothing; + * - a COMMENT on a page where PostHog is NOT LOADED goes to the fallback + * endpoint instead (see ./direct.ts), if one is configured, and the note + * under the box says so before the reader sends. A reader who explicitly + * opted out of PostHog never takes that path; * - a VOTE that did not send is held and retried — when the comment is sent, * on a short bounded poll, and once more as the page is being unloaded — * so consenting mid-visit does not cost the KPI the votes of exactly the @@ -44,6 +52,19 @@ import styles from './styles.module.css'; * would drop keyboard focus to . */ +type Outcome = + | 'open' + // Accepted by PostHog. + | 'sent' + // Dispatched to the fallback endpoint. `no-cors` hides the response, so this + // is all that can be said - the copy for it never claims delivery. + | 'dispatched' + // PostHog refused (not loaded with no fallback configured, opted out, or + // threw). Nothing left the page. + | 'refused' + // The fallback request itself failed: offline, or blocked. + | 'unreachable'; + const VOTE_KEY = (path: string) => `docs-feedback:${path}`; const COMMENT_KEY = (path: string) => `docs-feedback-comment:${path}`; const COMMENT_MAX = 1000; @@ -79,9 +100,10 @@ function Inner({ url, title }: PageFeedbackProps) { const path = url.endsWith('/') ? url : `${url}/`; // CONFIGURED, NOT COMPILED IN. The Web App URL changes every time the Apps - // Script is redeployed, and an empty value simply disables the fallback -- - // `sendDirectFeedback` returns false and the widget behaves exactly as it - // did before, telling the reader their note did not send. + // Script is redeployed, and an empty value disables the fallback: submit() + // never calls it, the note keeps its analytics wording, and the widget + // behaves exactly as it did before, telling the reader their note did not + // send. const { siteConfig } = useDocusaurusContext(); const feedbackEndpoint = String( (siteConfig.customFields as Record | undefined)?.feedbackEndpoint ?? '', @@ -95,7 +117,29 @@ function Inner({ url, title }: PageFeedbackProps) { const [helpful, setHelpful] = React.useState(null); const [recorded, setRecorded] = React.useState(false); const [comment, setComment] = React.useState(''); - const [outcome, setOutcome] = React.useState<'open' | 'sent' | 'undelivered'>('open'); + const [outcome, setOutcomeState] = React.useState('open'); + // The current outcome, readable after an await. The `outcome` a pending + // submit() closed over is from the render that created it, and can be stale + // by the time the request settles. + const outcomeRef = React.useRef('open'); + const setOutcome = (next: Outcome) => { + outcomeRef.current = next; + setOutcomeState(next); + }; + // In-flight guard. The ref blocks a second submit synchronously (a + // double-click, or Cmd+Enter then a click, lands before any re-render); the + // state drives the button. + const sendingRef = React.useRef(false); + const [sending, setSending] = React.useState(false); + // A reader can follow an in-site link while the fallback request is in + // flight; nothing may set state on the unmounted widget after that. + const mounted = React.useRef(true); + React.useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); const [attempts, setAttempts] = React.useState(0); // Bumped on every vote attempt, so the retry effect re-arms even when the // reader clicks the same button again and no other state changes. @@ -127,7 +171,8 @@ function Inner({ url, title }: PageFeedbackProps) { setHelpful(prior === 'up'); setRecorded(true); } - if (readSession(COMMENT_KEY(path)) === 'sent') setOutcome('sent'); + const priorComment = readSession(COMMENT_KEY(path)); + if (priorComment === 'sent' || priorComment === 'dispatched') setOutcome(priorComment); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -207,103 +252,118 @@ function Inner({ url, title }: PageFeedbackProps) { const submit = async () => { const text = comment.trim(); - if (!text) return; - // Flush the held vote first, so the pair arrives in the right order. - // - // Keep this UNCONDITIONAL and keep it here. - // - // What else could flush a held vote, and why none of it is cover. - // - // The poll stops retrying after RETRY_LIMIT * RETRY_MS, but clearing the - // interval does NOT unregister the `pagehide` listener or the unmount - // `lastChance()` - the effect does not re-run - so both stay armed. They - // are not equivalent to each other, and the difference matters: - // - // - UNMOUNT is reliable. On this SPA the widget unmounts on a client-side - // route change, which is how most readers leave a docs page, and the - // page is fully alive when it runs (see the note on the cleanup above). - // It is a better bet than the flush below, not a worse one - do not - // remove it on the strength of the next bullet. - // - PAGEHIDE is the unreliable one: a real unload, where an in-flight - // request may never leave the browser, and where an accepted event is - // not a delivered one. - // - // So this line has two jobs, depending on whether the poll is still running: - // - // - WHILE THE POLL RUNS, it keeps the vote AHEAD of the comment. The poll - // only fires every RETRY_MS, so if PostHog becomes ready between ticks - - // the reader accepts cookies, then presses Send a second later - the - // vote is still held here, and without this line the comment would go - // first and the vote would follow on the next tick or at unmount, out of - // order. It is not a no-op then, and not a no-op when PostHog is still - // missing either: the send fails and the vote is re-held for the poll. - // - ONCE THE POLL HAS EXHAUSTED, it is the only flush for the reader who - // neither navigates in-site nor unloads cleanly - the tab discarded, the - // process killed - having written a comment. - // - // Neither job depends on the other, so do not gate this line on the poll's - // state in either direction. - // - // Two edits remove it: - // - // - DELETING it as "the poll already covers it". Once exhausted the poll - // has stopped; before that it only fires on a tick, which cannot keep - // the vote ahead of a comment sent between ticks. - // - GATING it on the comment succeeding, `if (delivered && ...)`. The - // argument against this needs no scenario at all: the gate can only ever - // LOSE a vote and can never gain one. Where `delivered` is true the - // vote goes either way, so the gate buys nothing; where it is false the - // vote may still have been accepted - three of capturePostHogEvent's - // four false-exits are shared state read one line apart, but the fourth - // is a throw, and the vote's properties are a subset of the comment's, - // so a payload that throws on the comment need not have thrown on the - // vote. There is no case in which adding the gate helps. - // - // The ORDER of the two calls protects nothing on its own; it only keeps the - // pair in sequence. For the three shared-state exits, swapping them cannot - // change whether the VOTE is accepted. - // - // Why a lost vote matters rests on something NOT checkable from this repo: - // vote counts are read from `docs_page_feedback`. Note that this is a - // strict PREFIX of `docs_page_feedback_comment`, so an insight that matches - // by "contains" or by regex rather than exact equality already counts both - // - in which case this rationale is wrong today, not merely at risk of - // going wrong. Neither event name appears anywhere else in this repo, so - // confirm the insight uses an exact-match filter before relying on any of - // this. - if (heldVote.current !== null) sendVote(heldVote.current); - let delivered = capturePostHogEvent('docs_page_feedback_comment', { - ...base(), - helpful: helpful === true, - comment: text, - }); - // ONLY WHEN POSTHOG REFUSED. GTM loads PostHog behind the consent banner, - // so a reader who declines cookies and then writes us a paragraph reached - // nothing at all. This second path takes only what the first dropped -- - // never alongside a successful capture, or a consenting reader's comment - // would be recorded twice under two timestamps. - if (!delivered) { - delivered = await sendDirectFeedback(feedbackEndpoint, feedbackToken, { + if (!text || sendingRef.current) return; + sendingRef.current = true; + setSending(true); + let next: Outcome; + try { + // Flush the held vote first, so the pair arrives in the right order. + // + // Keep this UNCONDITIONAL and keep it here. + // + // What else could flush a held vote, and why none of it is cover. + // + // The poll stops retrying after RETRY_LIMIT * RETRY_MS, but clearing the + // interval does NOT unregister the `pagehide` listener or the unmount + // `lastChance()` - the effect does not re-run - so both stay armed. They + // are not equivalent to each other, and the difference matters: + // + // - UNMOUNT is reliable. On this SPA the widget unmounts on a client-side + // route change, which is how most readers leave a docs page, and the + // page is fully alive when it runs (see the note on the cleanup above). + // It is a better bet than the flush below, not a worse one - do not + // remove it on the strength of the next bullet. + // - PAGEHIDE is the unreliable one: a real unload, where an in-flight + // request may never leave the browser, and where an accepted event is + // not a delivered one. + // + // So this line has two jobs, depending on whether the poll is still running: + // + // - WHILE THE POLL RUNS, it keeps the vote AHEAD of the comment. The poll + // only fires every RETRY_MS, so if PostHog becomes ready between ticks - + // the reader accepts cookies, then presses Send a second later - the + // vote is still held here, and without this line the comment would go + // first and the vote would follow on the next tick or at unmount, out of + // order. It is not a no-op then, and not a no-op when PostHog is still + // missing either: the send fails and the vote is re-held for the poll. + // - ONCE THE POLL HAS EXHAUSTED, it is the only flush for the reader who + // neither navigates in-site nor unloads cleanly - the tab discarded, the + // process killed - having written a comment. + // + // Neither job depends on the other, so do not gate this line on the poll's + // state in either direction. + // + // Two edits remove it: + // + // - DELETING it as "the poll already covers it". Once exhausted the poll + // has stopped; before that it only fires on a tick, which cannot keep + // the vote ahead of a comment sent between ticks. + // - GATING it on the comment succeeding, `if (delivered && ...)`. The + // argument against this needs no scenario at all: the gate can only ever + // LOSE a vote and can never gain one. Where `delivered` is true the + // vote goes either way, so the gate buys nothing; where it is false the + // vote may still have been accepted - three of capturePostHogEvent's + // four false-exits are shared state read one line apart, but the fourth + // is a throw, and the vote's properties are a subset of the comment's, + // so a payload that throws on the comment need not have thrown on the + // vote. There is no case in which adding the gate helps. + // + // The ORDER of the two calls protects nothing on its own; it only keeps the + // pair in sequence. For the three shared-state exits, swapping them cannot + // change whether the VOTE is accepted. + // + // Why a lost vote matters rests on something NOT checkable from this repo: + // vote counts are read from `docs_page_feedback`. Note that this is a + // strict PREFIX of `docs_page_feedback_comment`, so an insight that matches + // by "contains" or by regex rather than exact equality already counts both + // - in which case this rationale is wrong today, not merely at risk of + // going wrong. Neither event name appears anywhere else in this repo, so + // confirm the insight uses an exact-match filter before relying on any of + // this. + if (heldVote.current !== null) sendVote(heldVote.current); + const result = capturePostHogEventWithResult('docs_page_feedback_comment', { ...base(), - // THREE-VALUED here, unlike the PostHog call above, which has always - // coerced. A reader can type before clicking either thumb, and a - // missing vote sent as `false` becomes a complaint they did not make. - helpful: helpful === null ? null : helpful, + helpful: helpful === true, comment: text, }); + if (result === 'captured') { + next = 'sent'; + } else if (result === 'not-loaded' && feedbackEndpoint) { + // ONLY WHEN POSTHOG IS NOT LOADED. Never alongside a successful + // capture, or a consenting reader's comment would be recorded twice + // under two timestamps; and never for `opted-out` or `error`, where + // the reader said no (or may have) and the comment must go nowhere. + const dispatched = await sendDirectFeedback(feedbackEndpoint, feedbackToken, { + ...base(), + // THREE-VALUED here, unlike the PostHog call above, which has always + // coerced. A reader can type before clicking either thumb, and a + // missing vote sent as `false` becomes a complaint they did not make. + helpful: helpful === null ? null : helpful, + comment: text, + }); + next = dispatched ? 'dispatched' : 'unreachable'; + } else { + next = 'refused'; + } + } finally { + sendingRef.current = false; } - if (delivered) writeSession(COMMENT_KEY(path), 'sent'); - const next = delivered ? 'sent' : 'undelivered'; + // Written even if the widget has gone, so a reload does not reopen a box + // whose comment already went out. + if (next === 'sent' || next === 'dispatched') writeSession(COMMENT_KEY(path), next); + if (!mounted.current) return; + setSending(false); // Exactly one channel, chosen by whether the message is new. // - // NEW outcome: the paragraph mounts and focus moves to it, which reads it in - // full. Announcing as well would say the same thing twice in two wordings. + // NEW outcome: the paragraph mounts and focus moves to it, which reads it + // in full. Announcing as well would say the same thing twice in two + // wordings. // - // REPEAT (a retry that failed again): nothing new mounts, so the region - // carries it - and focus must NOT move, because the reader is on the Send - // button they just pressed and moving focus would re-read the paragraph - // over the announcement. - if (next === outcome) { + // REPEAT (a retry that failed the same way again): nothing new mounts, so + // the region carries it - and focus must NOT move, because the reader is on + // the Send button they just pressed and moving focus would re-read the + // paragraph over the announcement. + if (next === outcomeRef.current) { say('That did not send. Your note is still in the box - press Send to try again.'); } else { hush(); @@ -322,7 +382,12 @@ function Inner({ url, title }: PageFeedbackProps) { confirmation.current?.focus(); }, [attempts]); - const showBox = helpful !== null && outcome !== 'sent'; + const done = outcome === 'sent' || outcome === 'dispatched'; + const showBox = helpful !== null && !done; + // Which path a comment sent NOW would take, so the note can say where it + // goes before the reader sends it. Re-evaluated on every render (each + // keystroke is one), so it follows a reader who accepts cookies mid-visit. + const usesFallback = Boolean(feedbackEndpoint) && postHogStatus() === 'not-loaded'; const left = COMMENT_MAX - comment.length; return ( @@ -365,13 +430,20 @@ function Inner({ url, title }: PageFeedbackProps) { {/* Sits BEFORE the row on purpose. It tells the reader to press Send, and a message placed after the button would have them tab out of the widget to reach it. */} - {outcome === 'undelivered' && ( + {outcome === 'refused' && (

That did not send: analytics is turned off or blocked in this browser, so nothing left this page. Your note is still in the box — press Send to try again, or copy it somewhere safe.

)} + {outcome === 'unreachable' && ( +

+ That could not be sent: the connection failed or something in this browser blocked + the request. Your note is still in the box — press Send to try again, or copy it + somewhere safe. +

+ )}