Skip to content

Conversation

@harlan-zw
Copy link
Collaborator

@harlan-zw harlan-zw commented Dec 20, 2025

πŸ€– Generated with Claude Code

πŸ”— Linked issue

❓ Type of change

  • πŸ“– Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • πŸ‘Œ Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

πŸ“š Description

  • 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

- 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>
@vercel
Copy link

vercel bot commented Dec 20, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
scripts-docs Ready Ready Preview, Comment Dec 20, 2025 5:55am
scripts-playground Ready Ready Preview, Comment Dec 20, 2025 5:55am

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 20, 2025

Open in StackBlitz

npm i https://pkg.pr.new/nuxt/scripts/@nuxt/scripts@568

commit: 54440ee

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>
Comment on lines 36 to 45
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
Copy link

@vercel vercel bot Dec 20, 2025

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:

  1. clientInit() starts an async import('posthog-js') operation that sets window.posthog later
  2. use() is called synchronously by the proxy mechanism to get the API reference
  3. If use() is called before the import completes, window.posthog doesn't exist yet
  4. use() returns undefined, 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.

Comment on lines +37 to +41
if (window.posthog)
return { posthog: window.posthog }
if (window.__posthogInitPromise)
return { posthog: window.__posthogInitPromise.then(() => window.posthog) }
return undefined
Copy link

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 undefined

Root 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:

  1. Returns synchronous wrapper functions for all PostHog methods
  2. Queues method calls until PostHog is ready
  3. Executes queued calls once __posthogInitPromise resolves
  4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants