From 066956c9ca9a27b51d32edc9d2c45b36e9bab779 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 25 Mar 2026 17:51:19 -0400 Subject: [PATCH 1/6] feat(react): add slim entrypoint for tree-shakeable usage without posthog-js runtime dependency --- packages/browser/package.json | 3 +- packages/browser/react/slim/package.json | 5 ++ packages/react/package.json | 3 +- packages/react/rollup.config.mjs | 54 ++++++++++++++++++- packages/react/slim/package.json | 5 ++ .../__tests__/PostHogErrorBoundary.test.tsx | 4 ++ packages/react/src/context/PostHogContext.ts | 14 +++-- .../react/src/context/PostHogProvider.tsx | 27 +++++++--- .../__tests__/PostHogProvider.test.tsx | 4 ++ packages/react/src/context/posthog-default.ts | 11 ++++ packages/react/src/helpers/error-helpers.ts | 2 +- .../react/src/hooks/useFeatureFlagPayload.ts | 2 +- .../react/src/hooks/useFeatureFlagResult.ts | 2 +- packages/react/src/index.ts | 5 ++ packages/react/src/slim.ts | 4 ++ 15 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 packages/browser/react/slim/package.json create mode 100644 packages/react/slim/package.json create mode 100644 packages/react/src/context/posthog-default.ts create mode 100644 packages/react/src/slim.ts diff --git a/packages/browser/package.json b/packages/browser/package.json index acb0628169..31655d55c6 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -50,7 +50,8 @@ "dist/*", "react/dist/**", "react/package.json", - "react/surveys/package.json" + "react/surveys/package.json", + "react/slim/package.json" ], "dependencies": { "@posthog/core": "workspace:*", diff --git a/packages/browser/react/slim/package.json b/packages/browser/react/slim/package.json new file mode 100644 index 0000000000..196ba0616b --- /dev/null +++ b/packages/browser/react/slim/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/umd/slim/index.js", + "module": "../dist/esm/slim/index.js", + "types": "../dist/types/slim/index.d.ts" +} diff --git a/packages/react/package.json b/packages/react/package.json index a04c8fd481..95186d2969 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -27,7 +27,8 @@ "files": [ "dist", "src", - "surveys" + "surveys", + "slim" ], "peerDependencies": { "@types/react": ">=16.8.0", diff --git a/packages/react/rollup.config.mjs b/packages/react/rollup.config.mjs index 34711cca77..29dcbb969b 100644 --- a/packages/react/rollup.config.mjs +++ b/packages/react/rollup.config.mjs @@ -64,6 +64,47 @@ const buildTypes = { plugins: [resolve(), dts()], } +/** + * Configuration for the slim build (no posthog-js runtime dependency) + */ +const buildSlimEsm = { + external: ['posthog-js', 'react'], + input: 'src/slim.ts', + output: { + file: 'dist/esm/slim/index.js', + format: 'esm', + sourcemap: true, + }, + plugins, +} + +const buildSlimUmd = { + external: ['posthog-js', 'react'], + input: 'src/slim.ts', + output: { + file: 'dist/umd/slim/index.js', + name: 'PosthogReactSlim', + format: 'umd', + sourcemap: true, + esModule: false, + globals: { + react: 'React', + 'posthog-js': 'posthog', + }, + }, + plugins, +} + +const buildSlimTypes = { + external: ['posthog-js', 'react'], + input: 'src/slim.ts', + output: { + file: 'dist/types/slim/index.d.ts', + format: 'es', + }, + plugins: [resolve(), dts()], +} + const buildSurveysEsm = { external: ['posthog-js', 'react'], input: 'src/surveys/index.ts', @@ -108,9 +149,20 @@ const buildSurveysTypes = { { src: 'dist/*', dest: '../browser/react/dist' }, { src: 'src/*', dest: '../browser/react/src' }, { src: 'surveys', dest: '../browser/react' }, + { src: 'slim', dest: '../browser/react' }, ], }), ], } -export default [buildEsm, buildUmd, buildTypes, buildSurveysEsm, buildSurveysUmd, buildSurveysTypes] +export default [ + buildEsm, + buildUmd, + buildTypes, + buildSlimEsm, + buildSlimUmd, + buildSlimTypes, + buildSurveysEsm, + buildSurveysUmd, + buildSurveysTypes, +] diff --git a/packages/react/slim/package.json b/packages/react/slim/package.json new file mode 100644 index 0000000000..196ba0616b --- /dev/null +++ b/packages/react/slim/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/umd/slim/index.js", + "module": "../dist/esm/slim/index.js", + "types": "../dist/types/slim/index.d.ts" +} diff --git a/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx b/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx index e4acb342c2..c6a501cd5f 100644 --- a/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx +++ b/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx @@ -4,6 +4,10 @@ import * as React from 'react' import { render } from '@testing-library/react' import { __POSTHOG_ERROR_MESSAGES, PostHogErrorBoundary } from '../PostHogErrorBoundary' import posthog from 'posthog-js' +import { setDefaultPostHogInstance } from '../../context/posthog-default' + +// Register posthog as the default instance (normally done by index.ts) +setDefaultPostHogInstance(posthog) describe('PostHogErrorBoundary component', () => { mockFunction(console, 'error') diff --git a/packages/react/src/context/PostHogContext.ts b/packages/react/src/context/PostHogContext.ts index d34a761eb2..ca4739769b 100644 --- a/packages/react/src/context/PostHogContext.ts +++ b/packages/react/src/context/PostHogContext.ts @@ -1,9 +1,17 @@ -import posthogJs, { BootstrapConfig } from 'posthog-js' +import type { PostHog } from 'posthog-js' +import type { BootstrapConfig } from 'posthog-js' import { createContext } from 'react' +import { getDefaultPostHogInstance } from './posthog-default' -export type PostHog = typeof posthogJs +export type { PostHog } +// The getter defers evaluation so that the full bundle's setDefaultPostHogInstance() +// call (which runs after module evaluation) has already executed by the time React +// accesses the default value. In the slim bundle no default is set, so client will +// be undefined — users must always provide a . export const PostHogContext = createContext<{ client: PostHog; bootstrap?: BootstrapConfig }>({ - client: posthogJs, + get client() { + return getDefaultPostHogInstance() as PostHog + }, bootstrap: undefined, }) diff --git a/packages/react/src/context/PostHogProvider.tsx b/packages/react/src/context/PostHogProvider.tsx index c49a6f0e57..4725544080 100644 --- a/packages/react/src/context/PostHogProvider.tsx +++ b/packages/react/src/context/PostHogProvider.tsx @@ -1,8 +1,9 @@ /* eslint-disable no-console */ -import posthogJs, { PostHogConfig } from 'posthog-js' +import type { PostHogConfig } from 'posthog-js' import React, { useEffect, useMemo, useRef } from 'react' import { PostHog, PostHogContext } from './PostHogContext' +import { getDefaultPostHogInstance } from './posthog-default' import { isDeepEqual } from '../utils/object-utils' interface PreviousInitialization { @@ -60,15 +61,22 @@ export function PostHogProvider({ children, client, apiKey, options }: WithOptio return client } + const defaultInstance = getDefaultPostHogInstance() + if (apiKey) { + if (!defaultInstance) { + console.error( + '[PostHog.js] You passed `apiKey` to PostHogProvider but are using the slim bundle (@posthog/react/slim) which does not include a default PostHog instance. Please pass a `client` prop instead.' + ) + } // return the global client, we'll initialize it in the useEffect - return posthogJs + return defaultInstance as PostHog } console.warn( '[PostHog.js] No `apiKey` or `client` were provided to `PostHogProvider`. Using default global `window.posthog` instance. You must initialize it manually. This is not recommended behavior.' ) - return posthogJs + return (defaultInstance ?? undefined) as unknown as PostHog // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, apiKey, JSON.stringify(options)]) // Stringify options to be a stable reference @@ -79,16 +87,21 @@ export function PostHogProvider({ children, client, apiKey, options }: WithOptio // if the user has passed their own client, assume they will also handle calling init(). return } + const defaultInstance = getDefaultPostHogInstance() + if (!defaultInstance) { + // slim bundle without a client — nothing to init + return + } const previousInitialization = previousInitializationRef.current if (!previousInitialization) { // If it's the first time running this, but it has been loaded elsewhere, warn the user about it. - if (posthogJs.__loaded) { + if (defaultInstance.__loaded) { console.warn('[PostHog.js] `posthog` was already loaded elsewhere. This may cause issues.') } // Init global client - posthogJs.init(apiKey, options) + defaultInstance.init(apiKey, options) // Keep track of whether the client was already initialized // This is used to prevent double initialization when running under React.StrictMode, and to know when options change @@ -111,10 +124,10 @@ export function PostHogProvider({ children, client, apiKey, options }: WithOptio ) } - // Changing options is better supported because we can just call `posthogJs.set_config(options)` + // Changing options is better supported because we can just call `defaultInstance.set_config(options)` // and they'll be good to go with their new config. The SDK will know how to handle the changes. if (options && !isDeepEqual(options, previousInitialization.options)) { - posthogJs.set_config(options) + defaultInstance.set_config(options) } // Keep track of the possibly-new set of apiKey and options diff --git a/packages/react/src/context/__tests__/PostHogProvider.test.tsx b/packages/react/src/context/__tests__/PostHogProvider.test.tsx index 4c597dbe5f..5b0376a493 100644 --- a/packages/react/src/context/__tests__/PostHogProvider.test.tsx +++ b/packages/react/src/context/__tests__/PostHogProvider.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { render, act } from '@testing-library/react' import { PostHogProvider, PostHog } from '..' import posthogJs from 'posthog-js' +import { setDefaultPostHogInstance } from '../posthog-default' // Mock posthog-js jest.mock('posthog-js', () => ({ @@ -13,6 +14,9 @@ jest.mock('posthog-js', () => ({ }, })) +// Register the mock as the default instance (normally done by index.ts) +setDefaultPostHogInstance(posthogJs) + describe('PostHogProvider component', () => { it('should render children components', () => { const posthog = {} as unknown as PostHog diff --git a/packages/react/src/context/posthog-default.ts b/packages/react/src/context/posthog-default.ts new file mode 100644 index 0000000000..88a76ff8d3 --- /dev/null +++ b/packages/react/src/context/posthog-default.ts @@ -0,0 +1,11 @@ +import type { PostHog } from 'posthog-js' + +let defaultPostHogInstance: PostHog | undefined + +export function setDefaultPostHogInstance(instance: PostHog): void { + defaultPostHogInstance = instance +} + +export function getDefaultPostHogInstance(): PostHog | undefined { + return defaultPostHogInstance +} diff --git a/packages/react/src/helpers/error-helpers.ts b/packages/react/src/helpers/error-helpers.ts index 5122c28bd2..9c802dcbff 100644 --- a/packages/react/src/helpers/error-helpers.ts +++ b/packages/react/src/helpers/error-helpers.ts @@ -1,6 +1,6 @@ import type { ErrorInfo } from 'react' import { PostHog } from '../context' -import { CaptureResult } from 'posthog-js' +import type { CaptureResult } from 'posthog-js' export const setupReactErrorHandler = ( client: PostHog, diff --git a/packages/react/src/hooks/useFeatureFlagPayload.ts b/packages/react/src/hooks/useFeatureFlagPayload.ts index b28fa75d7b..65db9ef23c 100644 --- a/packages/react/src/hooks/useFeatureFlagPayload.ts +++ b/packages/react/src/hooks/useFeatureFlagPayload.ts @@ -1,4 +1,4 @@ -import { JsonType } from 'posthog-js' +import type { JsonType } from 'posthog-js' import { useContext, useEffect, useState } from 'react' import { PostHogContext } from '../context' diff --git a/packages/react/src/hooks/useFeatureFlagResult.ts b/packages/react/src/hooks/useFeatureFlagResult.ts index af952f6ff8..47acafd8d9 100644 --- a/packages/react/src/hooks/useFeatureFlagResult.ts +++ b/packages/react/src/hooks/useFeatureFlagResult.ts @@ -1,4 +1,4 @@ -import { FeatureFlagResult } from 'posthog-js' +import type { FeatureFlagResult } from 'posthog-js' import { useContext, useEffect, useState } from 'react' import { PostHogContext } from '../context' import { isUndefined } from '../utils/type-utils' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c4c017bb0f..6a04526bdb 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,8 @@ +import posthogJs from 'posthog-js' +import { setDefaultPostHogInstance } from './context/posthog-default' + +setDefaultPostHogInstance(posthogJs) + export * from './context' export * from './hooks' export * from './components' diff --git a/packages/react/src/slim.ts b/packages/react/src/slim.ts new file mode 100644 index 0000000000..c4c017bb0f --- /dev/null +++ b/packages/react/src/slim.ts @@ -0,0 +1,4 @@ +export * from './context' +export * from './hooks' +export * from './components' +export * from './helpers' From ca41c04a01e753da27967b865f3115447b15cd7e Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 26 Mar 2026 16:07:00 -0400 Subject: [PATCH 2/6] fix(react): use dedicated slim PostHogProvider to avoid undefined-as-PostHog casts --- packages/browser/react/slim/package.json | 2 + packages/react/slim/package.json | 2 + .../__tests__/PostHogErrorBoundary.test.tsx | 19 +++++-- .../react/src/context/PostHogProvider.tsx | 17 ++---- .../react/src/context/PostHogProviderSlim.tsx | 23 ++++++++ .../context/__tests__/PostHogContext.test.tsx | 18 +++++++ .../__tests__/PostHogProvider.test.tsx | 11 ++-- .../__tests__/PostHogProviderSlim.test.tsx | 52 +++++++++++++++++++ packages/react/src/context/posthog-default.ts | 2 +- packages/react/src/slim.ts | 3 +- 10 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 packages/react/src/context/PostHogProviderSlim.tsx create mode 100644 packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx diff --git a/packages/browser/react/slim/package.json b/packages/browser/react/slim/package.json index 196ba0616b..29d6dbf5a6 100644 --- a/packages/browser/react/slim/package.json +++ b/packages/browser/react/slim/package.json @@ -1,4 +1,6 @@ { + "name": "@posthog/react-slim", + "private": true, "main": "../dist/umd/slim/index.js", "module": "../dist/esm/slim/index.js", "types": "../dist/types/slim/index.d.ts" diff --git a/packages/react/slim/package.json b/packages/react/slim/package.json index 196ba0616b..29d6dbf5a6 100644 --- a/packages/react/slim/package.json +++ b/packages/react/slim/package.json @@ -1,4 +1,6 @@ { + "name": "@posthog/react-slim", + "private": true, "main": "../dist/umd/slim/index.js", "module": "../dist/esm/slim/index.js", "types": "../dist/types/slim/index.d.ts" diff --git a/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx b/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx index c6a501cd5f..e7368fc0a8 100644 --- a/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx +++ b/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx @@ -6,10 +6,15 @@ import { __POSTHOG_ERROR_MESSAGES, PostHogErrorBoundary } from '../PostHogErrorB import posthog from 'posthog-js' import { setDefaultPostHogInstance } from '../../context/posthog-default' -// Register posthog as the default instance (normally done by index.ts) -setDefaultPostHogInstance(posthog) - describe('PostHogErrorBoundary component', () => { + beforeEach(() => { + setDefaultPostHogInstance(posthog) + }) + + afterEach(() => { + setDefaultPostHogInstance(undefined) + }) + mockFunction(console, 'error') mockFunction(console, 'warn') mockFunction(posthog, 'captureException') @@ -64,6 +69,14 @@ describe('PostHogErrorBoundary component', () => { }) describe('captureException processing', () => { + beforeEach(() => { + setDefaultPostHogInstance(posthog) + }) + + afterEach(() => { + setDefaultPostHogInstance(undefined) + }) + mockFunction(console, 'error') mockFunction(console, 'warn') mockFunction(posthog, 'capture') diff --git a/packages/react/src/context/PostHogProvider.tsx b/packages/react/src/context/PostHogProvider.tsx index 4725544080..b2138f032c 100644 --- a/packages/react/src/context/PostHogProvider.tsx +++ b/packages/react/src/context/PostHogProvider.tsx @@ -61,22 +61,17 @@ export function PostHogProvider({ children, client, apiKey, options }: WithOptio return client } - const defaultInstance = getDefaultPostHogInstance() + const defaultInstance = getDefaultPostHogInstance() as PostHog if (apiKey) { - if (!defaultInstance) { - console.error( - '[PostHog.js] You passed `apiKey` to PostHogProvider but are using the slim bundle (@posthog/react/slim) which does not include a default PostHog instance. Please pass a `client` prop instead.' - ) - } // return the global client, we'll initialize it in the useEffect - return defaultInstance as PostHog + return defaultInstance } console.warn( '[PostHog.js] No `apiKey` or `client` were provided to `PostHogProvider`. Using default global `window.posthog` instance. You must initialize it manually. This is not recommended behavior.' ) - return (defaultInstance ?? undefined) as unknown as PostHog + return defaultInstance // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, apiKey, JSON.stringify(options)]) // Stringify options to be a stable reference @@ -87,11 +82,7 @@ export function PostHogProvider({ children, client, apiKey, options }: WithOptio // if the user has passed their own client, assume they will also handle calling init(). return } - const defaultInstance = getDefaultPostHogInstance() - if (!defaultInstance) { - // slim bundle without a client — nothing to init - return - } + const defaultInstance = getDefaultPostHogInstance() as PostHog const previousInitialization = previousInitializationRef.current if (!previousInitialization) { diff --git a/packages/react/src/context/PostHogProviderSlim.tsx b/packages/react/src/context/PostHogProviderSlim.tsx new file mode 100644 index 0000000000..47444277a9 --- /dev/null +++ b/packages/react/src/context/PostHogProviderSlim.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import type { PostHog } from 'posthog-js' +import { PostHogContext } from './PostHogContext' + +/** + * Slim PostHogProvider for use with @posthog/react/slim. + * + * Only accepts a pre-initialized `client` instance. Does not support + * `apiKey`/`options` props since the slim bundle has no posthog-js runtime. + */ +export function PostHogProvider({ + client, + children, +}: { + client: PostHog + children?: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/packages/react/src/context/__tests__/PostHogContext.test.tsx b/packages/react/src/context/__tests__/PostHogContext.test.tsx index 9d37f08673..2b8e292e46 100644 --- a/packages/react/src/context/__tests__/PostHogContext.test.tsx +++ b/packages/react/src/context/__tests__/PostHogContext.test.tsx @@ -1,10 +1,28 @@ import * as React from 'react' import { render } from '@testing-library/react' import { PostHogProvider, PostHog } from '..' +import posthogJs from 'posthog-js' +import { setDefaultPostHogInstance } from '../posthog-default' + +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { + init: jest.fn(), + __loaded: false, + }, +})) describe('PostHogContext component', () => { const posthog = {} as unknown as PostHog + beforeEach(() => { + setDefaultPostHogInstance(posthogJs) + }) + + afterEach(() => { + setDefaultPostHogInstance(undefined) + }) + it('should return a client instance from the context if available', () => { render( diff --git a/packages/react/src/context/__tests__/PostHogProvider.test.tsx b/packages/react/src/context/__tests__/PostHogProvider.test.tsx index 5b0376a493..639ebe9f6e 100644 --- a/packages/react/src/context/__tests__/PostHogProvider.test.tsx +++ b/packages/react/src/context/__tests__/PostHogProvider.test.tsx @@ -14,10 +14,15 @@ jest.mock('posthog-js', () => ({ }, })) -// Register the mock as the default instance (normally done by index.ts) -setDefaultPostHogInstance(posthogJs) - describe('PostHogProvider component', () => { + beforeEach(() => { + setDefaultPostHogInstance(posthogJs) + }) + + afterEach(() => { + setDefaultPostHogInstance(undefined) + }) + it('should render children components', () => { const posthog = {} as unknown as PostHog const { getByText } = render( diff --git a/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx b/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx new file mode 100644 index 0000000000..0496d20a44 --- /dev/null +++ b/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { render } from '@testing-library/react' +import { PostHogProvider } from '../PostHogProviderSlim' +import { PostHogContext } from '../PostHogContext' +import type { PostHog } from 'posthog-js' + +// Do NOT call setDefaultPostHogInstance — this simulates the slim bundle +// where no default instance exists. + +function TestConsumer() { + const { client } = React.useContext(PostHogContext) + return
{client ? 'yes' : 'no'}
+} + +describe('PostHogProvider (slim)', () => { + it('renders children', () => { + const client = { config: {} } as unknown as PostHog + const { getByText } = render( + +
Hello
+
+ ) + expect(getByText('Hello')).toBeTruthy() + }) + + it('provides the client via context', () => { + const client = { config: {} } as unknown as PostHog + const { getByTestId } = render( + + + + ) + expect(getByTestId('client-exists').textContent).toBe('yes') + }) + + it('provides bootstrap from client config', () => { + const bootstrap = { featureFlags: { 'test-flag': true } } + const client = { config: { bootstrap } } as unknown as PostHog + + function BootstrapConsumer() { + const { bootstrap: ctx } = React.useContext(PostHogContext) + return
{JSON.stringify(ctx)}
+ } + + const { getByTestId } = render( + + + + ) + expect(JSON.parse(getByTestId('bootstrap').textContent!)).toEqual(bootstrap) + }) +}) diff --git a/packages/react/src/context/posthog-default.ts b/packages/react/src/context/posthog-default.ts index 88a76ff8d3..94adc6eef6 100644 --- a/packages/react/src/context/posthog-default.ts +++ b/packages/react/src/context/posthog-default.ts @@ -2,7 +2,7 @@ import type { PostHog } from 'posthog-js' let defaultPostHogInstance: PostHog | undefined -export function setDefaultPostHogInstance(instance: PostHog): void { +export function setDefaultPostHogInstance(instance: PostHog | undefined): void { defaultPostHogInstance = instance } diff --git a/packages/react/src/slim.ts b/packages/react/src/slim.ts index c4c017bb0f..15d1d6942b 100644 --- a/packages/react/src/slim.ts +++ b/packages/react/src/slim.ts @@ -1,4 +1,5 @@ -export * from './context' +export { PostHogContext, type PostHog } from './context/PostHogContext' +export { PostHogProvider } from './context/PostHogProviderSlim' export * from './hooks' export * from './components' export * from './helpers' From 54fdc05e66fdb5a376a8bc04c35f1ffa480ac0b8 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 26 Mar 2026 16:28:24 -0400 Subject: [PATCH 3/6] fix(react): improve test assertions and remove unnecessary optional chaining --- .../react/src/context/PostHogProviderSlim.tsx | 2 +- .../context/__tests__/PostHogContext.test.tsx | 11 ++++++++--- .../__tests__/PostHogProviderSlim.test.tsx | 19 +++++++++++++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/react/src/context/PostHogProviderSlim.tsx b/packages/react/src/context/PostHogProviderSlim.tsx index 47444277a9..a80f8e25da 100644 --- a/packages/react/src/context/PostHogProviderSlim.tsx +++ b/packages/react/src/context/PostHogProviderSlim.tsx @@ -16,7 +16,7 @@ export function PostHogProvider({ children?: React.ReactNode }) { return ( - + {children} ) diff --git a/packages/react/src/context/__tests__/PostHogContext.test.tsx b/packages/react/src/context/__tests__/PostHogContext.test.tsx index 2b8e292e46..e398ff4051 100644 --- a/packages/react/src/context/__tests__/PostHogContext.test.tsx +++ b/packages/react/src/context/__tests__/PostHogContext.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { render } from '@testing-library/react' -import { PostHogProvider, PostHog } from '..' +import { PostHogProvider, PostHog, PostHogContext } from '..' import posthogJs from 'posthog-js' import { setDefaultPostHogInstance } from '../posthog-default' @@ -24,11 +24,16 @@ describe('PostHogContext component', () => { }) it('should return a client instance from the context if available', () => { - render( + function ClientConsumer() { + const { client } = React.useContext(PostHogContext) + return
{client === posthog ? 'match' : 'mismatch'}
+ } + const { getByTestId } = render( -
Hello
+
) + expect(getByTestId('client').textContent).toBe('match') }) it("should not throw error if a client instance can't be found in the context", () => { diff --git a/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx b/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx index 0496d20a44..5bc9cf51aa 100644 --- a/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx +++ b/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx @@ -7,12 +7,19 @@ import type { PostHog } from 'posthog-js' // Do NOT call setDefaultPostHogInstance — this simulates the slim bundle // where no default instance exists. -function TestConsumer() { +let contextClient: PostHog | undefined + +function ClientConsumer() { const { client } = React.useContext(PostHogContext) - return
{client ? 'yes' : 'no'}
+ contextClient = client + return
consumed
} describe('PostHogProvider (slim)', () => { + afterEach(() => { + contextClient = undefined + }) + it('renders children', () => { const client = { config: {} } as unknown as PostHog const { getByText } = render( @@ -23,14 +30,14 @@ describe('PostHogProvider (slim)', () => { expect(getByText('Hello')).toBeTruthy() }) - it('provides the client via context', () => { + it('provides the exact client instance via context', () => { const client = { config: {} } as unknown as PostHog - const { getByTestId } = render( + render( - + ) - expect(getByTestId('client-exists').textContent).toBe('yes') + expect(contextClient).toBe(client) }) it('provides bootstrap from client config', () => { From e620e58ee7b6da3c7a444b9ae56419207f3215da Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 26 Mar 2026 16:48:33 -0400 Subject: [PATCH 4/6] fix(react): memoize slim provider context value and add explanatory comments --- packages/react/src/context/PostHogProvider.tsx | 4 ++++ packages/react/src/context/PostHogProviderSlim.tsx | 5 +++-- packages/react/src/context/posthog-default.ts | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react/src/context/PostHogProvider.tsx b/packages/react/src/context/PostHogProvider.tsx index b2138f032c..744f76d31a 100644 --- a/packages/react/src/context/PostHogProvider.tsx +++ b/packages/react/src/context/PostHogProvider.tsx @@ -61,6 +61,9 @@ export function PostHogProvider({ children, client, apiKey, options }: WithOptio return client } + // Indirection so the slim bundle can omit the posthog-js runtime import. + // Always defined here: the full entrypoint (index.ts) calls + // setDefaultPostHogInstance(posthogJs) before any component renders. const defaultInstance = getDefaultPostHogInstance() as PostHog if (apiKey) { @@ -82,6 +85,7 @@ export function PostHogProvider({ children, client, apiKey, options }: WithOptio // if the user has passed their own client, assume they will also handle calling init(). return } + // See comment in useMemo above for why this indirection exists. const defaultInstance = getDefaultPostHogInstance() as PostHog const previousInitialization = previousInitializationRef.current diff --git a/packages/react/src/context/PostHogProviderSlim.tsx b/packages/react/src/context/PostHogProviderSlim.tsx index a80f8e25da..3e2eb8cf12 100644 --- a/packages/react/src/context/PostHogProviderSlim.tsx +++ b/packages/react/src/context/PostHogProviderSlim.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import type { PostHog } from 'posthog-js' import { PostHogContext } from './PostHogContext' @@ -15,8 +15,9 @@ export function PostHogProvider({ client: PostHog children?: React.ReactNode }) { + const value = useMemo(() => ({ client, bootstrap: client.config?.bootstrap }), [client]) return ( - + {children} ) diff --git a/packages/react/src/context/posthog-default.ts b/packages/react/src/context/posthog-default.ts index 94adc6eef6..4966cb1c48 100644 --- a/packages/react/src/context/posthog-default.ts +++ b/packages/react/src/context/posthog-default.ts @@ -1,5 +1,8 @@ import type { PostHog } from 'posthog-js' +// Process-level singleton, mirroring the posthog-js default export which is +// itself a module-level singleton. Safe because setDefaultPostHogInstance is +// only called once at module evaluation time by src/index.ts. let defaultPostHogInstance: PostHog | undefined export function setDefaultPostHogInstance(instance: PostHog | undefined): void { From 90a8038ad32b6f5ad66472f29b16ece88fa83648 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 26 Mar 2026 17:41:32 -0400 Subject: [PATCH 5/6] chore(react): prettier --- packages/react/src/context/PostHogProviderSlim.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/react/src/context/PostHogProviderSlim.tsx b/packages/react/src/context/PostHogProviderSlim.tsx index 3e2eb8cf12..ecd3bcfdef 100644 --- a/packages/react/src/context/PostHogProviderSlim.tsx +++ b/packages/react/src/context/PostHogProviderSlim.tsx @@ -8,17 +8,7 @@ import { PostHogContext } from './PostHogContext' * Only accepts a pre-initialized `client` instance. Does not support * `apiKey`/`options` props since the slim bundle has no posthog-js runtime. */ -export function PostHogProvider({ - client, - children, -}: { - client: PostHog - children?: React.ReactNode -}) { +export function PostHogProvider({ client, children }: { client: PostHog; children?: React.ReactNode }) { const value = useMemo(() => ({ client, bootstrap: client.config?.bootstrap }), [client]) - return ( - - {children} - - ) + return {children} } From 97808741282ac511a961425eb48b4de16cdeeba6 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Fri, 27 Mar 2026 12:07:58 -0400 Subject: [PATCH 6/6] test: verify posthog-js not imported directly by slim bundle --- .../react/src/__tests__/slim-bundle.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/react/src/__tests__/slim-bundle.test.ts diff --git a/packages/react/src/__tests__/slim-bundle.test.ts b/packages/react/src/__tests__/slim-bundle.test.ts new file mode 100644 index 0000000000..872fafbba7 --- /dev/null +++ b/packages/react/src/__tests__/slim-bundle.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'fs' +import { resolve } from 'path' + +/** + * Guard against the slim bundle accidentally pulling in a runtime posthog-js + * dependency. The slim entrypoint's whole purpose is to avoid shipping the + * posthog-js runtime so consumers can bring their own client instance. + * + * Today this works because every shared module (hooks, components, helpers) + * only reaches PostHogContext.ts — never the full PostHogProvider.tsx — and + * PostHogProvider.tsx's posthog-js import is type-only. But if someone + * accidentally adds a runtime import, Rollup's `external` config means + * `posthog-js` would appear as a bare import/require in the output instead of + * being bundled, making it easy to grep for. + */ +describe('slim bundle', () => { + const distRoot = resolve(__dirname, '../../dist') + + it('ESM slim bundle has no runtime posthog-js imports', () => { + const content = readFileSync(resolve(distRoot, 'esm/slim/index.js'), 'utf-8') + const matches = content.match(/['"]posthog-js['"]/g) + expect(matches).toBeNull() + }) + + it('UMD slim bundle has no runtime posthog-js imports', () => { + const content = readFileSync(resolve(distRoot, 'umd/slim/index.js'), 'utf-8') + const matches = content.match(/['"]posthog-js['"]/g) + expect(matches).toBeNull() + }) +})