Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@ const config: Config = {
},
clientModules: [
require.resolve('./src/clientModules/agentSkillsNavLink.ts'),
require.resolve('./src/clientModules/translationGuard.ts'),
require.resolve('./src/clientModules/chunkLoadRecovery.ts'),
],
plugins: [
[
Expand Down
33 changes: 33 additions & 0 deletions src/clientModules/chunkLoadRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
isChunkLoadError,
reloadOnceForChunkError,
reportCrash,
} from '@site/src/utils/crashReporting';

/**
* Recover a first page load that asks for chunks a deploy has removed.
*
* A cached or long-open HTML page from the previous build references chunk
* hashes that no longer exist. Docusaurus already reloads when that happens
* during in-app navigation, but on the first load the route's chunks are
* fetched before hydration, and the failure is an unhandled rejection: the
* page stays as inert server-rendered HTML. Reload instead (see
* reloadOnceForChunkError for the loop guard). When the guard declines, the
* page will not start, so report it.
*
* Only until the app has started: after that, a failed chunk is a link
* preload on hover, and reloading the page the reader is on would be wrong.
*/
let started = false;

if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (event) => {
if (started || !isChunkLoadError(event.reason)) return;
if (!reloadOnceForChunkError(event.reason)) reportCrash(event.reason, 'first-load');
});
}

// Runs on the first render after hydration too, not only on navigation.
export function onRouteDidUpdate(): void {
started = true;
}
40 changes: 40 additions & 0 deletions src/clientModules/translationGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Keep React working when the browser translates the page.
*
* Chrome and Edge translation replace text nodes with their own <font>
* wrappers. When React later removes or inserts next to a node it believes it
* owns, the node is no longer where React left it, and the DOM throws
* "Failed to execute 'removeChild' on 'Node'", which takes the whole page
* down.
*
* The guard makes the two operations tolerate a node the translator moved:
* removing a node that is no longer a child does nothing, and inserting before
* one appends instead. Neither path runs unless the DOM would have thrown, and
* both log a warning so a real bug stays visible. This is the workaround from
* facebook/react#11538.
*/
if (typeof window !== 'undefined' && typeof Node === 'function' && Node.prototype) {
const originalRemoveChild = Node.prototype.removeChild;
Node.prototype.removeChild = function removeChild<T extends Node>(this: Node, child: T): T {
if (child.parentNode !== this) {
console.warn('Cannot remove a child from a different parent', child, this);
return child;
}
return originalRemoveChild.call(this, child) as T;
};

const originalInsertBefore = Node.prototype.insertBefore;
Node.prototype.insertBefore = function insertBefore<T extends Node>(
this: Node,
newNode: T,
referenceNode: Node | null,
): T {
if (referenceNode && referenceNode.parentNode !== this) {
console.warn('Cannot insert before a reference node from a different parent', referenceNode, this);
return originalInsertBefore.call(this, newNode, null) as T;
}
return originalInsertBefore.call(this, newNode, referenceNode) as T;
};
}

export {};
4 changes: 3 additions & 1 deletion src/components/HomePage/Frameworks/Frameworks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export default function Frameworks({ handleFrameworkClick }: FrameworksProps) {
<h4 className={style.text}>
Select Your Framework to View Supported Products and Features
</h4>
<form className={style.iconList}>
{/* Framework names are product names, and the labels change as the
selection does: left to a page translator they can crash React. */}
<form className={style.iconList} translate="no">
{frameworkCards.map((item) => {
return (
<div
Expand Down
4 changes: 3 additions & 1 deletion src/components/HomePage/Frameworks/FrameworksMobile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export default function FrameworksMobile({
</h4>
<BrowserOnly>
{() => (
<div className={style.frameworksMobile}>
// Framework names are product names, and the labels change as the
// selection does: left to a page translator they can crash React.
<div className={style.frameworksMobile} translate="no">
<div
className={style.field}
onClick={() => setOpenSelector(!openSelector)}
Expand Down
3 changes: 3 additions & 0 deletions src/components/SkillsCallout/CommandBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ const CommandBlock: React.FC<CommandBlockProps> = ({
<div className={styles.commandBlock}>
<pre
className={`${styles.command} ${prose ? styles.commandProse : ''}`}
// A translated command no longer runs, and translation of a node React
// owns can crash the page (see clientModules/translationGuard).
translate="no"
onCopy={handleTextCopy}
><code>{command}</code></pre>
<button
Expand Down
2 changes: 2 additions & 0 deletions src/components/SkillsCallout/ManualInstall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const ManualInstall: React.FC<ManualInstallProps> = ({ framework }) => {
<select
id="skills-agent-picker"
className={styles.manualSelect}
// Agent and platform names are product names.
translate="no"
value={agent.key}
onChange={handleChange}
>
Expand Down
30 changes: 6 additions & 24 deletions src/components/SkillsCallout/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,20 @@
type PostHogCapture = (event: string, props?: Record<string, unknown>) => void;

type PostHogLike = {
capture?: PostHogCapture;
has_opted_out_capturing?: () => boolean;
};
import { callPostHog } from '@site/src/utils/posthog';

/**
* Send an event to PostHog, and report whether it was accepted.
*
* PostHog is loaded by GTM behind the cookie banner, so `window.posthog` is
* simply absent until a reader consents - and on a first page view it usually
* is. The previous version swallowed that (`ph?.capture?.()`), which is fine
* for a fire-and-forget signal but not for anything that then tells the reader
* their input was delivered. Callers that don't care can ignore the result.
* PostHog is often not loaded yet (see callPostHog). The previous version
* swallowed that (`ph?.capture?.()`), which is fine for a fire-and-forget
* signal but not for anything that then tells the reader their input was
* delivered. Callers that don't care can ignore the result.
*
* `true` means PostHog accepted the event, NOT that it reached the server. Two
* cases it cannot see: the loader stub queues `capture` before the library
* arrives, so a script blocked after the stub installed still reads as
* accepted; and a network failure after that is invisible here. An explicit
* opt-out IS detected, since that is the case a reader can create themselves
* and would be most surprised to see reported as sent.
*
* A throwing capture is caught rather than propagated: this runs inside click
* handlers, and an exception escaping one leaves the UI inert.
*/
export function capturePostHogEvent(event: string, props?: Record<string, unknown>): boolean {
if (typeof window === 'undefined') return false;
const ph = (window as unknown as { posthog?: PostHogLike }).posthog;
if (typeof ph?.capture !== 'function') return false;
try {
if (ph.has_opted_out_capturing?.() === true) return false;
ph.capture(event, props);
return true;
} catch {
return false;
}
return callPostHog('capture', (ph) => ph.capture(event, props)) === 'sent';
}
10 changes: 10 additions & 0 deletions src/theme/Error/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";
import OriginalError from "@theme-original/Error";
import { useCrashHandling } from "@site/src/utils/crashReporting";

// The site-wide error boundary's fallback, for a crash outside a page's
// content (the layout, navbar or footer). See ErrorPageContent for the rest.
export default function Error(props) {
useCrashHandling(props.error, "site");
return <OriginalError {...props} />;
}
9 changes: 9 additions & 0 deletions src/theme/ErrorPageContent/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from "react";
import OriginalErrorPageContent from "@theme-original/ErrorPageContent";
import { useCrashHandling } from "@site/src/utils/crashReporting";

// The fallback of the error boundary around every page's content.
export default function ErrorPageContent(props) {
useCrashHandling(props.error, "page");
return <OriginalErrorPageContent {...props} />;
}
87 changes: 87 additions & 0 deletions src/utils/crashReporting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useEffect } from 'react';
import { callPostHog } from '@site/src/utils/posthog';

const reported = new WeakSet<object>();

// PostHog loads after the app starts, so a crash during or soon after
// hydration finds it missing. Wait for it this long before giving up.
const POSTHOG_WAIT_MS = 1000;
const POSTHOG_WAIT_ATTEMPTS = 15;

/**
* Send a crash caught by a React error boundary to PostHog.
*
* Exception autocapture only sees errors that reach window.onerror, and an
* error a boundary catches never does, so report it explicitly.
*
* Nothing is sent after an explicit opt-out, or when PostHog has not loaded
* within the wait. Each error is reported once: a boundary's fallback can
* render more than once for the same error.
*/
export function reportCrash(error: unknown, source: string, attempt = 0): void {
if (typeof window === 'undefined') return;
if (attempt === 0 && error && typeof error === 'object') {
if (reported.has(error)) return;
reported.add(error);
}
const result = callPostHog('captureException', (ph) =>
ph.captureException(error, { crash_source: source }),
);
if (result === 'unavailable' && attempt < POSTHOG_WAIT_ATTEMPTS) {
window.setTimeout(() => reportCrash(error, source, attempt + 1), POSTHOG_WAIT_MS);
}
}

/**
* A failed load of one of the build's JavaScript or CSS chunks.
*
* After a deploy, a page opened on the previous build asks for chunk hashes
* that no longer exist. webpack names the error ChunkLoadError; the CSS loader
* only says so in the message.
*/
export function isChunkLoadError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const { name, message } = error as { name?: unknown; message?: unknown };
return (
name === 'ChunkLoadError' ||
(typeof message === 'string' && /Loading (?:CSS )?chunk \S+ failed/.test(message))
);
}

const RELOAD_KEY_PREFIX = 'chunk-reload:';
// Long enough to stop a chunk that is really missing from reloading in a loop,
// short enough that the next deploy can be recovered in the same tab.
const RELOAD_COOLDOWN_MS = 10 * 60 * 1000;

/**
* Reload the page when a chunk failed to load, so the reader gets the current
* build instead of an error. Returns whether it reloaded.
*
* At most once per page per tab within the cooldown, recorded in
* sessionStorage. If sessionStorage is unavailable (blocked storage, some
* private modes) it does not reload at all, since nothing would stop a loop.
*/
export function reloadOnceForChunkError(error: unknown): boolean {
if (typeof window === 'undefined' || !isChunkLoadError(error)) return false;
const key = RELOAD_KEY_PREFIX + window.location.pathname;
try {
const last = Number(window.sessionStorage.getItem(key));
if (last && Date.now() - last < RELOAD_COOLDOWN_MS) return false;
window.sessionStorage.setItem(key, String(Date.now()));
} catch {
return false;
}
window.location.reload();
return true;
}

/**
* For an error boundary's fallback: reload for a chunk left behind by a
* deploy, and report anything else (or a chunk error the reload could not
* fix) as a crash.
*/
export function useCrashHandling(error: unknown, source: string): void {
useEffect(() => {
if (!reloadOnceForChunkError(error)) reportCrash(error, source);
}, [error, source]);
}
37 changes: 37 additions & 0 deletions src/utils/posthog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type PostHogLike = {
capture?: (event: string, props?: Record<string, unknown>) => void;
captureException?: (error: unknown, props?: Record<string, unknown>) => void;
has_opted_out_capturing?: () => boolean;
};

/**
* What became of a call: `unavailable` when PostHog (or the method) is not
* loaded, `opted-out` after the reader opted out, `failed` when the call threw.
* `sent` means PostHog accepted it, NOT that it reached the server.
*/
export type PostHogCallResult = 'sent' | 'unavailable' | 'opted-out' | 'failed';

/**
* Call one PostHog method under the rules every capture on the site follows.
*
* PostHog is loaded by GTM behind the cookie banner, so `window.posthog` is
* absent until it loads, and on a first page view it usually is. An explicit
* opt-out is respected. A throwing call is caught rather than propagated:
* these run inside click handlers and error fallbacks, and an exception
* escaping one leaves the UI inert.
*/
export function callPostHog(
method: 'capture' | 'captureException',
call: (ph: Required<PostHogLike>) => void,
): PostHogCallResult {
if (typeof window === 'undefined') return 'unavailable';
const ph = (window as unknown as { posthog?: PostHogLike }).posthog;
if (typeof ph?.[method] !== 'function') return 'unavailable';
try {
if (ph.has_opted_out_capturing?.() === true) return 'opted-out';
call(ph as Required<PostHogLike>);
return 'sent';
} catch {
return 'failed';
}
}
Loading