fix: preserve company context across browser hot reload (#14482)

## Thinking Path

> - Paperclip uses a shared company context for browser providers and
consumers.
> - Vite can load a new consumer module while an older provider is
mounted.
> - Recreating the context disconnects that consumer from the mounted
provider.
> - The consumer then reports a missing provider even though one is in
its React ancestry.
> - This change preserves the context object across development module
refreshes.
> - Bounded global error diagnostics distinguish development bundles and
otherwise context-free promise rejections.

## Linked Issues or Issue Description

**What happened?**
A refreshed company consumer can throw `useCompany must be used within a
CompanyProvider`. A real Vite and Chromium reproduction confirms that a
retained provider and a refreshed consumer can hold different context
objects. Global promise rejections also lack the bounded document state
already attached to React boundary errors.

**Expected behavior**
A refreshed consumer should read the mounted provider. Error reports
should identify the loaded bundle mode and bounded browser state while
preserving monitoring opt-in, sign-out, and privacy behavior.

**Steps to reproduce**
Run `pnpm test:e2e:browser-context`. The isolated Vite fixture renders
the real CompanyProvider, imports a new timestamped consumer module, and
renders that consumer below the retained provider. The test fails before
the context change and passes after it. The SDK regression invokes its
real unhandled-rejection handler with an undefined reason.

## What Changed

- Keep the React context object in Vite's per-module `hot.data`. Account
values stay in React.
- Add an isolated browser regression with mocked API responses and no
live instance, discovered by the existing Chrome CI shards.
- Add document-state diagnostics to global errors while preserving
earlier boundary snapshots.
- Tag events with development or production bundle mode and the type of
an unhandled rejected value.
- Document the new test command and diagnostic fields.

## Verification

- Company context, browser context, and Sentry suites: 59 passed.
- `pnpm test:e2e:browser-context`: passed in Chromium. The original
context code fails the reproduction.
- Real SDK tests preserve DSN/sign-out behavior and omit request
context, breadcrumbs, and private DOM data.
- `pnpm check:token-gates`: passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- Local `pnpm test:run` exited in the general-server phase: 13,819
passed, 86 skipped, 21 failures in unchanged filesystem and host-port
suites. Cache permission and long-path failures also reproduce on the
unmodified base; seven other failures involve local runtime port
ownership. This is not a full local pass.
- Full Linux CI and review passed at a0b5270662 (53 successful checks;
two conditional checks skipped). Three jobs interrupted by a runner
shutdown passed on rerun. Greptile is 5/5 with no unresolved comments.
The real browser regression also passed in the normal Chrome CI shard
(3.2 seconds).
- Code-owner approval remains required because the dedicated test
command changes `package.json`.
- Scanned the diff and PR text for credentials and private data before
pushing.

## Risks

The context cache applies only to development hot reload. It retains the
context object, not account state. Production continues to create an
ordinary React context. The diagnostic hook adds only bounded state and
type values, preserves boundary snapshots, and returns the original
event if enrichment fails. It does not suppress errors or restore raw
breadcrumbs. The global rejection diagnostics do not identify the
promise's originating operation by themselves.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, code editing,
and command execution. The runtime does not expose a more specific model
revision or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused suites and
Chromium regression; full local limitations documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-28 16:15:14 -07:00
1 parent 0be2afcca6
commit faf72cb1d5
9 files changed
+199 -20

No files matched your search

+13
View File
@@ -1641,3 +1641,16 @@ disconnected, visible active queries refresh every 15 seconds. This fallback
stops when the socket opens, the tab is hidden, or the provider unmounts. A
reconnected socket also refreshes visible queries to recover missed events.
Run log views retain their existing HTTP polling fallback.
### Company context during hot reload
The company React context retains only its object identity in Vite's per-module
`hot.data`. Provider values remain in the mounted React tree and keep their normal
account scope. This lets a refreshed consumer read a provider from the preceding
module version. Production builds do not use the development cache.
Run `pnpm test:e2e:browser-context` to test this with real Vite modules and
Chromium. The test starts its own loopback Vite server and mocks API responses;
it needs no running Paperclip instance or provider credentials. The same spec lives
in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it
on pull requests.
+10 -2
View File
@@ -418,13 +418,21 @@ Application and route error-boundary reports also include:
URLs, arguments, and unrecognized lines are omitted. Parsing examines at
most 16 KiB of input. Production builds preserve function names so this
trace remains useful after minification; this adds some bundle size.
All browser error reports, including global promise rejections, include:
- `browser_build_mode`: `development` or `production`, from the loaded bundle.
- `browser_rejection_kind`: the primitive type of an unhandled rejected value
(or `null`). The diagnostic does not read object properties or copy the value.
- `browser_state`: document readiness, visibility, and a boolean indicating
the `translated-ltr` or `translated-rtl` root class used by browser translation.
The marker is evidence of DOM translation, not proof of the error's cause;
its absence does not exclude other translators or DOM-changing extensions.
These fields are captured at the failure, before asynchronous reporting, and
attached only to that event. They include no component props, DOM text, HTML,
Boundary state is captured at the failure, before asynchronous reporting.
Global reports without that snapshot read document state before sending.
All fields are attached only to that event. They include no component props,
DOM text, HTML,
element identifiers, arbitrary CSS classes, route, or query string. Failed
diagnostic reads do not prevent the original exception from being reported.
The monitoring gate and sign-out behavior still apply. This context does not
+1
View File
@@ -71,6 +71,7 @@
"test:e2e:runner:models:update": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/openrouter-models-update.ts",
"test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts",
"test:runner-recovery": "vitest run server/src/services/native-runtime/native-replacement-evidence.test.ts server/src/services/native-runtime/stopped-codex-turn.test.ts server/src/services/native-runtime/native-safe-replacement.test.ts",
"test:e2e:browser-context": "playwright test --config tests/e2e/playwright-company-context.config.ts",
"test:e2e:runner:browser-support": "playwright test --config tests/runner-e2e/playwright-support.config.ts",
"test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts",
"test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json",
+77
View File
@@ -0,0 +1,77 @@
import path from "node:path";
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "../../ui/node_modules/vite/dist/node/index.js";
let server: ViteDevServer;
let origin: string;
const uiRoot = path.resolve(import.meta.dirname, "../../ui");
const probePath = path.join(uiRoot, "src/__company_context_probe.tsx");
// Use real Vite module instances and React contexts. Re-importing a refreshed
// consumer must still see a provider retained from the previous module version.
test.beforeAll(async () => {
server = await createServer({
root: uiRoot,
configFile: false,
resolve: { alias: { "@": path.join(uiRoot, "src") } },
esbuild: { jsx: "automatic" },
server: { host: "127.0.0.1", port: 0 },
plugins: [{
name: "company-context-regression",
resolveId(id) { if (id === "/src/__company_context_probe.tsx") return probePath; },
load(id) {
if (id !== probePath) return;
return `
import React from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CompanyProvider, useCompany } from "/src/context/CompanyContext.tsx";
const root = createRoot(document.getElementById("root"));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function render(hook, generation) {
function Consumer() { return <main>{generation + ":" + (hook().selectedCompanyId ?? "loading")}</main>; }
root.render(<QueryClientProvider client={client}><CompanyProvider><Consumer /></CompanyProvider></QueryClientProvider>);
}
render(useCompany, "initial");
window.refreshConsumer = async () => {
const refreshed = await import(/* @vite-ignore */ "/src/context/CompanyContext.tsx?t=" + Date.now());
render(refreshed.useCompany, "refreshed");
};
`;
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== "/") return next();
res.setHeader("Content-Type", "text/html");
res.end('<div id="root"></div><script type="module" src="/@vite/client"></script><script type="module" src="/src/__company_context_probe.tsx"></script>');
});
},
}],
});
await server.listen();
const address = server.httpServer!.address();
if (!address || typeof address === "string") throw new Error("Missing test server address");
origin = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => { await server?.close(); });
test("a refreshed company consumer reads the retained provider", async ({ page }) => {
await page.route("**/api/**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (!pathname.startsWith("/api/")) return route.continue();
const body = pathname === "/api/auth/get-session"
? { user: { id: "test-user" }, session: { userId: "test-user" } }
: pathname === "/api/companies"
? [{ id: "test-company", name: "Test company", status: "active" }]
: [];
await route.fulfill({ json: body });
});
const errors: string[] = [];
page.on("pageerror", (error) => { errors.push(error.message); });
await page.goto(origin);
await expect(page.getByRole("main")).toHaveText("initial:test-company");
await page.evaluate(() => (window as unknown as { refreshConsumer: () => Promise<void> }).refreshConsumer());
await expect(page.getByRole("main")).toHaveText("refreshed:test-company");
expect(errors).toEqual([]);
});
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "company-context-refresh.spec.ts",
workers: 1,
timeout: 30_000,
use: {
headless: true,
...(process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL
? { channel: process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL }
: {}),
},
outputDir: "./test-results",
reporter: "list",
});
+6 -1
View File
@@ -6,6 +6,7 @@ import {
useMemo,
useRef,
useState,
type Context,
type ReactNode,
} from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -46,7 +47,11 @@ interface CompanyContextValue {
const STORAGE_KEY = "paperclip.selectedCompanyId";
const CompanyContext = createContext<CompanyContextValue | null>(null);
// A refresh can replace consumers before a mounted provider is replaced. Keep
// their context identity in Vite's per-module data; never store account state.
const CompanyContext: Context<CompanyContextValue | null> =
import.meta.hot?.data?.companyContext ?? createContext<CompanyContextValue | null>(null);
if (import.meta.hot?.data) import.meta.hot.data.companyContext = CompanyContext;
export function resolveBootstrapCompanySelection(input: {
companies: Array<Pick<Company, "id">>;
+9 -4
View File
@@ -21,9 +21,8 @@ export function sanitizeComponentStack(stack: string | null | undefined): string
return frames.length ? `\n${frames.join("\n")}` : undefined;
}
/** A bounded snapshot, taken at the error rather than when the capture queue runs. */
export function buildBrowserErrorContext(details: BrowserErrorDetails) {
const componentStack = sanitizeComponentStack(details.componentStack);
/** Bounded document state; never read page content, locations, or identifiers. */
export function readBrowserErrorState() {
const browserState: {
ready_state?: "loading" | "interactive" | "complete";
visibility_state?: "visible" | "hidden";
@@ -47,11 +46,17 @@ export function buildBrowserErrorContext(details: BrowserErrorDetails) {
browserState.translation_marker = classes.contains("translated-ltr") || classes.contains("translated-rtl");
} catch { /* diagnostic unavailable */ }
}
return browserState;
}
/** A bounded snapshot, taken at the error rather than when the capture queue runs. */
export function buildBrowserErrorContext(details: BrowserErrorDetails) {
const componentStack = sanitizeComponentStack(details.componentStack);
return {
tags: { react_error_boundary: details.boundary },
contexts: {
react: componentStack ? { componentStack } : {},
browser_state: browserState,
browser_state: readBrowserErrorState(),
},
};
}
+39 -12
View File
@@ -327,7 +327,11 @@ describe("browser error diagnostics with the real SDK", () => {
init: (options: Parameters<typeof Sentry.init>[0]) => Sentry.init({
...options,
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSend: (event) => { events.push(event as unknown as Record<string, unknown>); return event; },
beforeSend: async (event, hint) => {
const enriched = await options?.beforeSend?.(event, hint) ?? event;
events.push(enriched as unknown as Record<string, unknown>);
return enriched;
},
}),
}));
const gate = await importFreshSentry();
@@ -354,7 +358,7 @@ describe("browser error diagnostics with the real SDK", () => {
},
});
expect(events[1]).not.toHaveProperty("contexts.react");
expect(events[1]).not.toHaveProperty("contexts.browser_state");
expect(events[1]).toHaveProperty("contexts.browser_state.translation_marker", false);
expect(events[1]).not.toHaveProperty("tags.react_error_boundary");
for (const event of events) {
expect(event).not.toHaveProperty("request");
@@ -424,12 +428,12 @@ describe("buildBrowserSentryInitOptions", () => {
expect(options.tracesSampleRate).toBe(0);
});
it("holds no beforeSend hook and no custom filter function", async () => {
it("adds diagnostics without a transaction filter", async () => {
const { buildBrowserSentryInitOptions } = await importFreshSentry();
const options = buildBrowserSentryInitOptions(DSN);
expect(options.beforeSend).toBeUndefined();
expect(options.beforeSend).toBeTypeOf("function");
expect(options.beforeSendTransaction).toBeUndefined();
});
@@ -467,10 +471,8 @@ describe("captured event shape against the real @sentry/browser SDK", () => {
/**
* Initialize the real SDK with this module's exact options, plus a
* transport stub so no event leaves the test process, plus `beforeSend`
* so the test can inspect the resolved event before it would have been
* sent. `beforeSend` here is test-only introspection — the shipped module
* adds no `beforeSend` of its own (see the "holds no beforeSend hook"
* test above).
* so the test can inspect the resolved event after the production diagnostic
* hook runs, before it would have been sent.
*/
async function initRealSentryForTest(
onEvent: (event: Record<string, unknown>) => void,
@@ -478,17 +480,42 @@ describe("captured event shape against the real @sentry/browser SDK", () => {
) {
const { buildBrowserSentryInitOptions } = await importFreshSentry();
const Sentry = await import("@sentry/browser");
const options = buildBrowserSentryInitOptions(DSN, environment);
Sentry.init({
...buildBrowserSentryInitOptions(DSN, environment),
...options,
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSend: (event) => {
onEvent(event as unknown as Record<string, unknown>);
return event;
beforeSend: async (event, hint) => {
const enriched = await options.beforeSend?.(event, hint) ?? event;
onEvent(enriched as unknown as Record<string, unknown>);
return enriched;
},
});
return Sentry;
}
it("enriches an undefined global rejection without page data or suppression", async () => {
let captured: Record<string, unknown> | null = null;
const Sentry = await initRealSentryForTest((event) => { captured = event; }, "staging");
const previousHandler = window.onunhandledrejection;
try {
expect(previousHandler).toBeTypeOf("function");
document.documentElement.classList.add("translated-ltr", "private-class");
window.onunhandledrejection?.call(window, { reason: undefined } as PromiseRejectionEvent);
await Sentry.flush(2000);
expect(captured).toMatchObject({
tags: { browser_rejection_kind: "undefined", browser_build_mode: "development" },
contexts: { browser_state: { translation_marker: true } },
exception: { values: [{ type: "UnhandledRejection" }] },
});
expect(captured).not.toHaveProperty("request");
expect((captured as unknown as Record<string, unknown>).breadcrumbs).toBeUndefined();
expect(JSON.stringify(captured)).not.toContain("private-class");
} finally {
document.documentElement.classList.remove("translated-ltr", "private-class");
await Sentry.close();
}
});
it.each([
["staging", "staging"],
["production", "production"],
+28 -1
View File
@@ -38,7 +38,7 @@
// (`GlobalHandlers`), the two React error boundaries, deduplicates a repeat
// event (`Dedupe`), and links a caused-by chain (`LinkedErrors`).
import { buildBrowserErrorContext, type BrowserErrorDetails } from "./browser-error-context";
import { buildBrowserErrorContext, readBrowserErrorState, type BrowserErrorDetails } from "./browser-error-context";
let queue: Promise<void> = Promise.resolve();
@@ -175,6 +175,33 @@ export function buildBrowserSentryInitOptions(
: undefined,
tracesSampleRate: 0,
sendDefaultPii: false,
beforeSend: (event, hint) => {
// Global handlers do not pass through our React boundaries. Give their
// reports the same bounded document state, without URLs or breadcrumbs.
// Preserve a boundary's earlier snapshot across the asynchronous queue.
try {
event.contexts = {
...event.contexts,
browser_state: event.contexts?.browser_state ?? readBrowserErrorState(),
};
event.tags = {
...event.tags,
browser_build_mode: import.meta.env.DEV ? "development" : "production",
};
if (event.exception?.values?.some((value) =>
value.mechanism?.type === "onunhandledrejection"
|| value.mechanism?.type === "auto.browser.global_handlers.onunhandledrejection",
)) {
// The SDK supplies the rejected value directly. Classify without
// reading object properties, coercing strings, or copying its value.
const reason = hint.originalException;
event.tags.browser_rejection_kind = reason === null ? "null" : typeof reason;
}
} catch {
// Diagnostics must not replace or discard the original error.
}
return event;
},
integrations: (defaults) =>
defaults.filter(
(integration) => integration.name !== "HttpContext" && integration.name !== "Breadcrumbs",