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
3 changes: 2 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
7 changes: 7 additions & 0 deletions packages/browser/react/slim/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 2 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"files": [
"dist",
"src",
"surveys"
"surveys",
"slim"
],
"peerDependencies": {
"@types/react": ">=16.8.0",
Expand Down
54 changes: 53 additions & 1 deletion packages/react/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
]
7 changes: 7 additions & 0 deletions packages/react/slim/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
30 changes: 30 additions & 0 deletions packages/react/src/__tests__/slim-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
14 changes: 11 additions & 3 deletions packages/react/src/context/PostHogContext.ts
Original file line number Diff line number Diff line change
@@ -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 <PostHogProvider client={…}>.
export const PostHogContext = createContext<{ client: PostHog; bootstrap?: BootstrapConfig }>({
client: posthogJs,
get client() {
return getDefaultPostHogInstance() as PostHog
},
bootstrap: undefined,
})
22 changes: 15 additions & 7 deletions packages/react/src/context/PostHogProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/react/src/context/PostHogProviderSlim.tsx
Original file line number Diff line number Diff line change
@@ -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 <PostHogContext.Provider value={value}>{children}</PostHogContext.Provider>
}
29 changes: 26 additions & 3 deletions packages/react/src/context/__tests__/PostHogContext.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid="client">{client === posthog ? 'match' : 'mismatch'}</div>
}
const { getByTestId } = render(
<PostHogProvider client={posthog}>
<div>Hello</div>
<ClientConsumer />
</PostHogProvider>
)
expect(getByTestId('client').textContent).toBe('match')
})

it("should not throw error if a client instance can't be found in the context", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/context/__tests__/PostHogProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions packages/react/src/context/__tests__/PostHogProviderSlim.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>consumed</div>
}

describe('PostHogProvider (slim)', () => {
afterEach(() => {
contextClient = undefined
})

it('renders children', () => {
const client = { config: {} } as unknown as PostHog
const { getByText } = render(
<PostHogProvider client={client}>
<div>Hello</div>
</PostHogProvider>
)
expect(getByText('Hello')).toBeTruthy()
})

it('provides the exact client instance via context', () => {
const client = { config: {} } as unknown as PostHog
render(
<PostHogProvider client={client}>
<ClientConsumer />
</PostHogProvider>
)
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 <div data-testid="bootstrap">{JSON.stringify(ctx)}</div>
}

const { getByTestId } = render(
<PostHogProvider client={client}>
<BootstrapConsumer />
</PostHogProvider>
)
expect(JSON.parse(getByTestId('bootstrap').textContent!)).toEqual(bootstrap)
})
})
Loading
Loading