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
29 changes: 9 additions & 20 deletions packages/react-grab/src/core/log-intro.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LOGO_SVG } from "./logo-svg.js";
import { isExtensionContext } from "../utils/is-extension-context.js";
import { fetchLatestVersion } from "../utils/fetch-latest-version.js";

export const logIntro = () => {
try {
Expand All @@ -10,25 +10,14 @@ export const logIntro = () => {
`background: #330039; color: #ffffff; border: 1px solid #d75fcb; padding: 4px 4px 4px 24px; border-radius: 4px; background-image: url("${logoDataUri}"); background-size: 16px 16px; background-repeat: no-repeat; background-position: 4px center; display: inline-block; margin-bottom: 4px;`,
"",
);
if (navigator.onLine && version && !isExtensionContext()) {
fetch(
`https://www.react-grab.com/api/version?source=browser&t=${Date.now()}`,
{
referrerPolicy: "origin",
keepalive: true,
priority: "low",
cache: "no-store",
} as RequestInit,
)
.then((response) => response.text())
.then((latestVersion) => {
if (latestVersion && latestVersion !== version) {
console.warn(
`[React Grab] v${version} is outdated (latest: v${latestVersion})`,
);
}
})
.catch(() => null);
if (process.env.DISTRIBUTION !== "npm") {
void fetchLatestVersion().then((latestVersion) => {
if (latestVersion) {
console.warn(
`[React Grab] v${version} is outdated (latest: v${latestVersion})`,
);
}
});
}
// HACK: Entire intro log is best-effort; never block initialization
} catch {}
Expand Down
18 changes: 18 additions & 0 deletions packages/react-grab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type {
} from "./types.js";

import { init } from "./core/index.js";
import { fetchLatestVersion } from "./utils/fetch-latest-version.js";
import type { Plugin, ReactGrabAPI } from "./types.js";

declare global {
Expand Down Expand Up @@ -114,4 +115,21 @@ if (typeof window !== "undefined" && !window.__REACT_GRAB_DISABLED__) {
window.dispatchEvent(
new CustomEvent("react-grab:init", { detail: globalApi }),
);

if (process.env.DISTRIBUTION === "npm" && globalApi) {
void fetchLatestVersion().then((latestVersion) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing handler for successful script load in NPM distribution version update flow causes stale globalApi state and no event notification when newer version loads from unpkg

Fix on Vercel

if (!latestVersion) return;

globalApi?.dispose();
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The CDN swap calls globalApi?.dispose(), permanently destroying all registered plugins. Neither the successfully-loaded CDN script (which initializes in its own scope with an empty plugin list) nor the onerror fallback (which calls bare init()) has any mechanism to re-register the plugins that were flushed during the original initialization. Any plugins registered via registerPlugin() before the swap are silently and permanently lost.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/index.ts, line 123:

<comment>The CDN swap calls `globalApi?.dispose()`, permanently destroying all registered plugins. Neither the successfully-loaded CDN script (which initializes in its own scope with an empty plugin list) nor the `onerror` fallback (which calls bare `init()`) has any mechanism to re-register the plugins that were flushed during the original initialization. Any plugins registered via `registerPlugin()` before the swap are silently and permanently lost.</comment>

<file context>
@@ -114,4 +115,21 @@ if (typeof window !== "undefined" && !window.__REACT_GRAB_DISABLED__) {
+    void fetchLatestVersion().then((latestVersion) => {
+      if (!latestVersion) return;
+
+      globalApi?.dispose();
+      setGlobalApi(null);
+
</file context>
Fix with Cubic

setGlobalApi(null);

const script = document.createElement("script");
script.src = `https://unpkg.com/react-grab@${latestVersion}/dist/index.global.js`;
script.onerror = () => {
script.remove();
setGlobalApi(init());
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The onerror fallback re-initializes the API via init() but never re-dispatches the react-grab:init event. Consumers (e.g., MCP/Relay clients) that listen with { once: true } will have already captured and detached on the first dispatch, leaving them holding a reference to the now-disposed original API. They will never receive the replacement API from either the CDN success path or this error fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/index.ts, line 130:

<comment>The `onerror` fallback re-initializes the API via `init()` but never re-dispatches the `react-grab:init` event. Consumers (e.g., MCP/Relay clients) that listen with `{ once: true }` will have already captured and detached on the first dispatch, leaving them holding a reference to the now-disposed original API. They will never receive the replacement API from either the CDN success path or this error fallback.</comment>

<file context>
@@ -114,4 +115,21 @@ if (typeof window !== "undefined" && !window.__REACT_GRAB_DISABLED__) {
+      script.src = `https://unpkg.com/react-grab@${latestVersion}/dist/index.global.js`;
+      script.onerror = () => {
+        script.remove();
+        setGlobalApi(init());
+      };
+      document.head.appendChild(script);
</file context>
Fix with Cubic

};
document.head.appendChild(script);
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CDN swap permanently loses all registered plugins

High Severity

The CDN swap disposes globalApi, destroying all registered plugins, but pendingPlugins was already emptied by flushPendingPlugins at initialization. Neither the CDN success path (which runs in a fresh IIFE scope with its own empty pendingPlugins) nor the onerror fallback (which calls bare init()) has any way to restore those plugins. Any plugins registered via registerPlugin() before or during initialization are silently and permanently lost when a newer version is detected.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swap invalidates API for once-only event listeners

High Severity

The react-grab:init event is dispatched with the initial globalApi at line 115, which the CDN swap later disposes. Consumers like MCP and Relay clients listen with { once: true }, so they capture the first (soon-to-be-disposed) API reference, detach their listener, and never receive the replacement API from the CDN script's own init event. Additionally, the onerror fallback never dispatches react-grab:init at all.

Fix in Cursor Fix in Web

}
43 changes: 43 additions & 0 deletions packages/react-grab/src/utils/fetch-latest-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { isExtensionContext } from "./is-extension-context.js";

const isNewerSemver = (latest: string, current: string): boolean => {
const latestParts = latest.split(".").map(Number);
const currentParts = current.split(".").map(Number);
for (let index = 0; index < 3; index++) {
const latestPart = latestParts[index] ?? 0;
const currentPart = currentParts[index] ?? 0;
if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}
Comment on lines +4 to +11
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const latestParts = latest.split(".").map(Number);
const currentParts = current.split(".").map(Number);
for (let index = 0; index < 3; index++) {
const latestPart = latestParts[index] ?? 0;
const currentPart = currentParts[index] ?? 0;
if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}
// Parse semantic versions, extracting major.minor.patch and ignoring pre-release/metadata
const parseSemver = (version: string): [number, number, number] => {
// Remove metadata (e.g., "+build123") - it doesn't affect version precedence
const baseVersion = version.split("+")[0];
// Remove pre-release identifier (e.g., "-beta.1") - we'll handle it separately
const versionMatch = baseVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!versionMatch) {
// Fallback for non-standard versions
const parts = baseVersion.split(".").map(Number);
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
return [parseInt(versionMatch[1], 10), parseInt(versionMatch[2], 10), parseInt(versionMatch[3], 10)];
};
const [latestMajor, latestMinor, latestPatch] = parseSemver(latest);
const [currentMajor, currentMinor, currentPatch] = parseSemver(current);
if (latestMajor !== currentMajor) return latestMajor > currentMajor;
if (latestMinor !== currentMinor) return latestMinor > currentMinor;
if (latestPatch !== currentPatch) return latestPatch > currentPatch;

The isNewerSemver function improperly parses semantic versions with pre-release identifiers, metadata, or non-standard formats, causing incorrect version comparisons

Fix on Vercel

return false;
};

export const fetchLatestVersion = async (): Promise<string | null> => {
const currentVersion = process.env.VERSION;
if (!currentVersion || !navigator.onLine || isExtensionContext()) {
return null;
}

try {
const response = await fetch(
`https://www.react-grab.com/api/version?source=browser&t=${Date.now()}`,
{
referrerPolicy: "origin",
keepalive: true,
priority: "low",
cache: "no-store",
} as RequestInit,
);
const latestVersion = await response.text();
if (
!latestVersion ||
latestVersion === currentVersion ||
!isNewerSemver(latestVersion, currentVersion)
) {
return null;
}
return latestVersion;
} catch {
return null;
}
};
5 changes: 5 additions & 0 deletions packages/react-grab/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const browserBuildConfig: Options = {
env: {
...DEFAULT_OPTIONS.env,
VERSION: version,
DISTRIBUTION: "cdn",
},
format: ["iife"],
globalName: "globalThis.__REACT_GRAB_MODULE__",
Expand All @@ -91,6 +92,10 @@ const libraryBuildConfig: Options = {
...DEFAULT_OPTIONS,
clean: false,
entry: ["./src/index.ts", "./src/core/index.tsx", "./src/primitives.ts"],
env: {
...DEFAULT_OPTIONS.env,
DISTRIBUTION: "npm",
},
format: ["cjs", "esm"],
loader: {
".css": "text",
Expand Down
Loading