-
Notifications
You must be signed in to change notification settings - Fork 75
feat(registry): add PostHog analytics script #568
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Add useScriptPostHog composable using npm package pattern - Add posthog-js as optional peer dependency - Support US/EU region configuration - Add common config options (autocapture, capturePageview, etc.) - Add documentation with usage examples π€ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
commit: |
Move state management to window object to handle HMR correctly and prevent shared state issues across multiple useScriptPostHog calls. π€ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| use() { | ||
| return window.posthog ? { posthog: window.posthog } : undefined | ||
| }, | ||
| }, | ||
| clientInit: import.meta.server | ||
| ? undefined | ||
| : () => { | ||
| // Use window for state to handle HMR correctly | ||
| if (window.__posthogInitPromise || window.posthog) | ||
| return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use() function checks if window.posthog exists synchronously, but the clientInit function starts an asynchronous import operation that completes later. If use() is called before the async initialization finishes, it will return undefined, causing proxy.posthog to be undefined.
View Details
π Patch Details
diff --git a/src/runtime/registry/posthog.ts b/src/runtime/registry/posthog.ts
index b7b5e87..bc738b6 100644
--- a/src/runtime/registry/posthog.ts
+++ b/src/runtime/registry/posthog.ts
@@ -27,6 +27,7 @@ declare global {
}
export function useScriptPostHog<T extends PostHogApi>(_options?: PostHogInput) {
+ let readyPromise: Promise<PostHog | undefined> = Promise.resolve(undefined)
return useRegistryScript<T, typeof PostHogOptions>('posthog', options => ({
scriptInput: {
src: '', // No external script - using npm package
@@ -34,7 +35,7 @@ export function useScriptPostHog<T extends PostHogApi>(_options?: PostHogInput)
schema: import.meta.dev ? PostHogOptions : undefined,
scriptOptions: {
use() {
- return window.posthog ? { posthog: window.posthog } : undefined
+ return { posthog: window.posthog! }
},
},
clientInit: import.meta.server
@@ -44,6 +45,30 @@ export function useScriptPostHog<T extends PostHogApi>(_options?: PostHogInput)
if (window.__posthogInitPromise || window.posthog)
return
+ // Initialize a queue/stub synchronously to avoid race conditions
+ // This ensures use() always returns a valid object
+ const queue: Array<{ method: string, args: any[] }> = []
+ const stub: any = new Proxy({}, {
+ get: (target, method: string | symbol) => {
+ if (typeof method !== 'string')
+ return undefined
+ return (...args: any[]) => {
+ // Queue the call if the real posthog hasn't loaded yet
+ if (!window.posthog || window.posthog === stub) {
+ queue.push({ method, args })
+ return
+ }
+ // Once loaded, call the real method
+ const fn = (window.posthog as any)[method]
+ if (typeof fn === 'function') {
+ return fn.apply(window.posthog, args)
+ }
+ }
+ },
+ })
+
+ window.posthog = stub as any as PostHog
+
const region = options?.region || 'us'
const apiHost = region === 'eu'
? 'https://eu.i.posthog.com'
@@ -64,8 +89,22 @@ export function useScriptPostHog<T extends PostHogApi>(_options?: PostHogInput)
config.disable_session_recording = options.disableSessionRecording
window.posthog = posthog.init(options?.apiKey || '', config)
+
+ // Replay queued calls
+ for (const { method, args } of queue) {
+ const fn = (window.posthog as any)[method]
+ if (typeof fn === 'function') {
+ fn.apply(window.posthog, args)
+ }
+ }
+
return window.posthog
+ }).then((result) => {
+ readyPromise = Promise.resolve(result)
+ return result
})
+
+ readyPromise = window.__posthogInitPromise
},
}), _options)
}
Analysis
Race condition between async initialization and synchronous use() in PostHog integration
What fails: The useScriptPostHog() composable's use() function returns undefined if called before the asynchronous import('posthog-js') completes, preventing the proxy mechanism from functioning correctly.
How to reproduce:
// In a Vue component:
const { proxy } = useScriptPostHog({ apiKey: 'test-key' })
// Immediately call PostHog method
proxy.posthog.capture('event') // Fails with "Cannot read properties of undefined"The issue occurs because:
clientInit()starts an asyncimport('posthog-js')operation that setswindow.posthoglateruse()is called synchronously by the proxy mechanism to get the API reference- If
use()is called before the import completes,window.posthogdoesn't exist yet use()returnsundefined, breaking the proxy system which relies on a valid object reference
Result: Calls to proxy methods fail if made before async initialization completes. The proxy system from @unhead/vue relies on the use() function returning a valid object reference to queue calls.
Expected behavior: The use() function should always return a valid object, similar to other analytics scripts like Crisp (see src/runtime/registry/crisp.ts) which initialize a stub synchronously.
Fix: Initialize window.posthog as a queuing stub synchronously in clientInit(), then replace it with the real PostHog instance once the async import completes. Calls made before initialization are queued and replayed after the library loads.
| if (window.posthog) | ||
| return { posthog: window.posthog } | ||
| if (window.__posthogInitPromise) | ||
| return { posthog: window.__posthogInitPromise.then(() => window.posthog) } | ||
| return undefined |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use() function returns a Promise when the PostHog initialization is pending, but the type signature and usage pattern expect a synchronous PostHog object. This will cause runtime errors when trying to call PostHog methods on the proxy.
View Details
π Patch Details
diff --git a/src/runtime/registry/posthog.ts b/src/runtime/registry/posthog.ts
index 8e9f2db..717d0fb 100644
--- a/src/runtime/registry/posthog.ts
+++ b/src/runtime/registry/posthog.ts
@@ -27,6 +27,7 @@ declare global {
}
export function useScriptPostHog<T extends PostHogApi>(_options?: PostHogInput) {
+ let readyPromise: Promise<PostHog | undefined> = Promise.resolve(undefined)
return useRegistryScript<T, typeof PostHogOptions>('posthog', options => ({
scriptInput: {
src: '', // No external script - using npm package
@@ -37,8 +38,31 @@ export function useScriptPostHog<T extends PostHogApi>(_options?: PostHogInput)
if (window.posthog)
return { posthog: window.posthog }
if (window.__posthogInitPromise)
- return { posthog: window.__posthogInitPromise.then(() => window.posthog) }
- return undefined
+ readyPromise = window.__posthogInitPromise
+ // Return a proxy object that wraps PostHog methods to wait for initialization
+ return {
+ posthog: new Proxy({} as PostHog, {
+ get(target, prop: string | symbol) {
+ const posthog = window.posthog
+ // Check the actual value on the current window.posthog or assume it's a function for common PostHog methods
+ const value = posthog?.[prop as keyof PostHog]
+
+ // Always return a function wrapper for method-like properties
+ // This ensures we can queue calls even if posthog isn't ready yet
+ return (...args: any[]) => {
+ if (window.posthog && typeof window.posthog[prop as keyof PostHog] === 'function') {
+ return (window.posthog[prop as keyof PostHog] as any)(...args)
+ }
+ // Queue the call to execute once PostHog is ready
+ readyPromise.then(() => {
+ if (window.posthog && typeof window.posthog[prop as keyof PostHog] === 'function') {
+ (window.posthog[prop as keyof PostHog] as any)(...args)
+ }
+ })
+ }
+ },
+ }),
+ }
},
},
clientInit: import.meta.server
Analysis
PostHog use() function returns Promise instead of synchronous API object
What fails: The useScriptPostHog() composable's use() function returns { posthog: Promise<PostHog> } when initialization is pending, but the framework expects a synchronous PostHogApi object. This causes calls like proxy.posthog.capture() to fail with "capture is not a function" since you cannot call methods on a Promise.
How to reproduce:
const { proxy } = useScriptPostHog({ apiKey: 'phc_test' })
// During PostHog initialization (while __posthogInitPromise exists but window.posthog is undefined):
proxy.posthog.capture('button_clicked', { button_name: 'test' })
// TypeError: Cannot read property 'capture' of undefinedRoot cause: In src/runtime/registry/posthog.ts line 40, when window.__posthogInitPromise exists, the function returned:
return { posthog: window.__posthogInitPromise.then(() => window.posthog) }This violates the @unhead/vue useScript contract which requires use() to return a synchronous object, not a Promise. See Unhead documentation: "A function that resolves the scripts API... returns a synchronous object."
Solution: Implemented a Proxy-based wrapper that:
- Returns synchronous wrapper functions for all PostHog methods
- Queues method calls until PostHog is ready
- Executes queued calls once
__posthogInitPromiseresolves - Matches the pattern used by other async scripts like Crisp
This allows proxy.posthog.capture() to be called immediately, with calls queued if PostHog isn't ready yet.
π€ Generated with Claude Code
π Linked issue
β Type of change
π Description