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..29d6dbf5a6 --- /dev/null +++ b/packages/browser/react/slim/package.json @@ -0,0 +1,7 @@ +{ + "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/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..29d6dbf5a6 --- /dev/null +++ b/packages/react/slim/package.json @@ -0,0 +1,7 @@ +{ + "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/__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() + }) +}) diff --git a/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx b/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx index e4acb342c2..e7368fc0a8 100644 --- a/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx +++ b/packages/react/src/components/__tests__/PostHogErrorBoundary.test.tsx @@ -4,8 +4,17 @@ 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' describe('PostHogErrorBoundary component', () => { + beforeEach(() => { + setDefaultPostHogInstance(posthog) + }) + + afterEach(() => { + setDefaultPostHogInstance(undefined) + }) + mockFunction(console, 'error') mockFunction(console, 'warn') mockFunction(posthog, 'captureException') @@ -60,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/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..744f76d31a 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,20 @@ 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) { // return the global client, we'll initialize it in the useEffect - return posthogJs + 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 posthogJs + return defaultInstance // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, apiKey, JSON.stringify(options)]) // Stringify options to be a stable reference @@ -79,16 +85,18 @@ 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 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 +119,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/PostHogProviderSlim.tsx b/packages/react/src/context/PostHogProviderSlim.tsx new file mode 100644 index 0000000000..ecd3bcfdef --- /dev/null +++ b/packages/react/src/context/PostHogProviderSlim.tsx @@ -0,0 +1,14 @@ +import React, { useMemo } 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 }) { + const value = useMemo(() => ({ client, bootstrap: client.config?.bootstrap }), [client]) + return {children} +} diff --git a/packages/react/src/context/__tests__/PostHogContext.test.tsx b/packages/react/src/context/__tests__/PostHogContext.test.tsx index 9d37f08673..e398ff4051 100644 --- a/packages/react/src/context/__tests__/PostHogContext.test.tsx +++ b/packages/react/src/context/__tests__/PostHogContext.test.tsx @@ -1,16 +1,39 @@ 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' + +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( + 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__/PostHogProvider.test.tsx b/packages/react/src/context/__tests__/PostHogProvider.test.tsx index 4c597dbe5f..639ebe9f6e 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', () => ({ @@ -14,6 +15,14 @@ jest.mock('posthog-js', () => ({ })) 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..5bc9cf51aa --- /dev/null +++ b/packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx @@ -0,0 +1,59 @@ +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. + +let contextClient: PostHog | undefined + +function ClientConsumer() { + const { client } = React.useContext(PostHogContext) + contextClient = client + return
consumed
+} + +describe('PostHogProvider (slim)', () => { + afterEach(() => { + contextClient = undefined + }) + + it('renders children', () => { + const client = { config: {} } as unknown as PostHog + const { getByText } = render( + +
Hello
+
+ ) + expect(getByText('Hello')).toBeTruthy() + }) + + it('provides the exact client instance via context', () => { + const client = { config: {} } as unknown as PostHog + render( + + + + ) + expect(contextClient).toBe(client) + }) + + 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 new file mode 100644 index 0000000000..4966cb1c48 --- /dev/null +++ b/packages/react/src/context/posthog-default.ts @@ -0,0 +1,14 @@ +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 { + 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..15d1d6942b --- /dev/null +++ b/packages/react/src/slim.ts @@ -0,0 +1,5 @@ +export { PostHogContext, type PostHog } from './context/PostHogContext' +export { PostHogProvider } from './context/PostHogProviderSlim' +export * from './hooks' +export * from './components' +export * from './helpers'