Skip to content
Merged
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
61 changes: 61 additions & 0 deletions .claude/plans/543-plausible-broken.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
## DONE

# Fix: Plausible Analytics new script not working (#543)

## Problem
Users report that the new Plausible script format (with `scriptId`) loads correctly in DOM but no events are sent.

## Analysis

### Root Cause
The `clientInit` stub uses bare `plausible` identifier inconsistently with `window.plausible`:

```js
// Current code (plausible-analytics.ts:187)
window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function (i) { plausible.o = i || {} }
```

Issues:
1. **Inconsistent window reference**: First part uses `window.plausible`, second part uses bare `plausible`
2. **Module scope**: In ES modules (strict mode), bare identifier resolution differs from non-module scripts
3. **Compare to GA**: Google Analytics uses `w` (window) consistently throughout its clientInit

### How Plausible's new script works
The `pa-{scriptId}.js` script:
1. Checks `plausible.o && S(plausible.o)` on load to pick up pre-init options
2. The stub's `plausible.init()` stores options in `plausible.o`
3. Script has domain hardcoded, doesn't need `data-domain` attribute

### Verification
Plausible script expected stub format:
```js
window.plausible = window.plausible || {}
plausible.o && S(plausible.o) // If .o exists, initialize with those options
```

Our stub needs to set `plausible.o` before script loads, which it does via:
```js
plausible.init = function(i) { plausible.o = i || {} }
window.plausible.init(initOptions)
```

## Fix

Update `clientInit` to use `window.plausible` consistently (like GA does):

```ts
clientInit() {
const w = window as any
w.plausible = w.plausible || function () { (w.plausible.q = w.plausible.q || []).push(arguments) }
w.plausible.init = w.plausible.init || function (i: PlausibleInitOptions) { w.plausible.o = i || {} }
w.plausible.init(initOptions)
}
```

## Files to modify
- `src/runtime/registry/plausible-analytics.ts`: Fix clientInit stub pattern

## Test plan
1. Run existing tests
2. Test playground with plausible-analytics-v2.vue
3. Verify script loads and init options are picked up
3 changes: 3 additions & 0 deletions docs/content/scripts/content/youtube-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ The `ScriptYouTubePlayer` component accepts the following props:
- `trigger`: The trigger event to load the YouTube Player. Default is `mousedown`. See [Element Event Triggers](/docs/guides/script-triggers#element-event-triggers) for more information.
- `placeholderAttrs`: The attributes for the placeholder image. Default is `{ loading: 'lazy' }`.
- `aboveTheFold`: Optimizes the placeholder image for above-the-fold content. Default is `false`.
- `placeholderObjectFit`: The `object-fit` CSS property for the placeholder image. Default is `cover`. Useful for non-16:9 videos like YouTube Shorts.

All script options from the [YouTube IFrame Player API](https://developers.google.com/youtube/iframe_api_reference) are supported on the `playerVars` prop, please consult the [Supported paramters](https://developers.google.com/youtube/player_parameters#Parameters) for full documentation.

Expand All @@ -92,6 +93,7 @@ export interface YouTubeProps {
playerVars?: YT.PlayerVars
width?: number
height?: number
placeholderObjectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
}
```

Expand Down Expand Up @@ -155,6 +157,7 @@ const emits = defineEmits<{
'playback-quality-change': [e: YT.OnPlaybackQualityChangeEvent, target: YT.Player]
'playback-rate-change': [e: YT.OnPlaybackRateChangeEvent, target: YT.Player]
'error': [e: YT.OnErrorEvent, target: YT.Player]
'api-change': [e: YT.PlayerEvent, target: YT.Player]
}>()
```

Expand Down
78 changes: 63 additions & 15 deletions src/runtime/components/ScriptYouTubePlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// @ts-nocheck

/// <reference types="youtube" />
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { HTMLAttributes, ImgHTMLAttributes, Ref } from 'vue'
import { defu } from 'defu'
import { useHead } from 'nuxt/app'
Expand Down Expand Up @@ -44,6 +44,12 @@ const props = withDefaults(defineProps<{
playerOptions?: YT.PlayerOptions
thumbnailSize?: YoutubeThumbnailSize
webp?: boolean
/**
* Object-fit for the placeholder image.
*
* @default 'cover'
*/
placeholderObjectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
}>(), {
cookies: false,
trigger: 'mousedown',
Expand All @@ -53,6 +59,7 @@ const props = withDefaults(defineProps<{
playerVars: { autoplay: 0, playsinline: 1 },
width: 640,
height: 360,
placeholderObjectFit: 'cover',
})

const emits = defineEmits<{
Expand All @@ -61,6 +68,7 @@ const emits = defineEmits<{
'playback-quality-change': [e: YT.OnPlaybackQualityChangeEvent, target: YT.Player]
'playback-rate-change': [e: YT.OnPlaybackRateChangeEvent, target: YT.Player]
'error': [e: YT.OnErrorEvent, target: YT.Player]
'api-change': [e: YT.PlayerEvent, target: YT.Player]
}>()
const events: (keyof YT.Events)[] = [
'onReady',
Expand All @@ -73,49 +81,91 @@ const events: (keyof YT.Events)[] = [
const rootEl = ref()
const youtubeEl = ref()
const ready = ref(false)
// Track this instance's trigger state separately from the shared script load state
const isTriggered = ref(false)
const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl })

// Load script immediately (shared across all players), but track this player's trigger separately
const script = useScriptYouTubePlayer({
scriptOptions: {
// Use immediate trigger so script loads when ANY player needs it
// Each player will wait for its own trigger before creating iframe
trigger,
},
})
const { onLoaded, status } = script

const player: Ref<YT.Player | undefined> = ref()
let clickTriggered = false
if (props.trigger === 'mousedown' && trigger instanceof Promise) {
const clickTriggered = ref(false)

// Track when THIS player's trigger fires
if (trigger instanceof Promise) {
trigger.then((triggered) => {
if (triggered) {
clickTriggered = true
isTriggered.value = true
if (props.trigger === 'mousedown') {
clickTriggered.value = true
}
}
})
}
else {
// Immediate trigger
isTriggered.value = true
}

// Watch for videoId changes (outside onLoaded to avoid creating multiple watchers)
const stopVideoIdWatch = watch(() => props.videoId, (newId) => {
if (ready.value && player.value) {
player.value.loadVideoById(newId)
}
})

// Cleanup player on unmount
onBeforeUnmount(() => {
stopVideoIdWatch()
player.value?.destroy()
})

onMounted(() => {
onLoaded(async (instance) => {
// Wait for THIS player's trigger before creating iframe (fixes #339)
if (!isTriggered.value && trigger instanceof Promise) {
const triggered = await trigger
if (!triggered) return // Component was disposed
}

// Guard against stale refs during layout transitions (fixes #297)
if (!youtubeEl.value) return

const YouTube = instance.YT instanceof Promise ? await instance.YT : instance.YT
await new Promise<void>((resolve) => {
if (typeof YT.Player === 'undefined')
YouTube.ready(resolve)
else
resolve()
})

// Double-check ref is still valid after async operations
if (!youtubeEl.value) return

player.value = new YT.Player(youtubeEl.value, {
host: !props.cookies ? 'https://www.youtube-nocookie.com' : 'https://www.youtube.com',
...props,
videoId: props.videoId,
width: props.width,
height: props.height,
playerVars: props.playerVars,
...props.playerOptions,
events: Object.fromEntries(events.map(event => [event, (e: any) => {
const emitEventName = event.replace(/([A-Z])/g, '-$1').replace('on-', '').toLowerCase()
// @ts-expect-error untyped
emits(emitEventName, e)
if (event === 'onReady') {
ready.value = true
if (clickTriggered) {
if (clickTriggered.value) {
player.value?.playVideo()
clickTriggered = false
clickTriggered.value = false
}
watch(() => props.videoId, () => {
player.value?.loadVideoById(props.videoId)
})
}
}])),
})
Expand Down Expand Up @@ -158,18 +208,16 @@ const placeholder = computed(() => `https://i.ytimg.com/${props.webp ? 'vi_webp'
const isFallbackPlaceHolder = ref(false)

if (import.meta.server) {
// dns-prefetch https://i.vimeocdn.com
useHead({
link: [
{
key: `nuxt-script-youtube-img`,
key: 'nuxt-script-youtube-img-preconnect',
rel: props.aboveTheFold ? 'preconnect' : 'dns-prefetch',
href: 'https://i.ytimg.com',
},
props.aboveTheFold
// we can preload the placeholder image
? {
key: `nuxt-script-youtube-img`,
key: `nuxt-script-youtube-img-preload-${props.videoId}`,
rel: 'preload',
as: 'image',
href: placeholder.value,
Expand All @@ -186,7 +234,7 @@ const placeholderAttrs = computed(() => {
loading: props.aboveTheFold ? 'eager' : 'lazy',
style: {
width: '100%',
objectFit: 'contain',
objectFit: props.placeholderObjectFit,
height: '100%',
},
onLoad(payload) {
Expand Down
42 changes: 42 additions & 0 deletions test/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,48 @@ describe('basic', () => {
})
})

describe('youtube', () => {
it('multiple players only load clicked player', {
timeout: 20000,
}, async () => {
const { page } = await createPage('/youtube-multiple')
await page.waitForTimeout(500)

// All players should be waiting initially
const player1Status = await page.$eval('#player1-status', el => el.textContent?.trim())
const player2Status = await page.$eval('#player2-status', el => el.textContent?.trim())
const player3Status = await page.$eval('#player3-status', el => el.textContent?.trim())

expect(player1Status).toBe('waiting')
expect(player2Status).toBe('waiting')
expect(player3Status).toBe('waiting')

// Click only player 2
await page.click('#player2')
await page.waitForTimeout(3000) // Wait for YouTube iframe to load

// Only player 2 should be ready, others still waiting
const player1StatusAfter = await page.$eval('#player1-status', el => el.textContent?.trim())
const player2StatusAfter = await page.$eval('#player2-status', el => el.textContent?.trim())
const player3StatusAfter = await page.$eval('#player3-status', el => el.textContent?.trim())

expect(player1StatusAfter).toBe('waiting')
expect(player2StatusAfter).toBe('ready')
expect(player3StatusAfter).toBe('waiting')

// Now click player 1
await page.click('#player1')
await page.waitForTimeout(3000)

// Player 1 and 2 should be ready, player 3 still waiting
const player1StatusFinal = await page.$eval('#player1-status', el => el.textContent?.trim())
const player3StatusFinal = await page.$eval('#player3-status', el => el.textContent?.trim())

expect(player1StatusFinal).toBe('ready')
expect(player3StatusFinal).toBe('waiting')
})
})

describe('third-party-capital', () => {
it('expect GA to collect data', {
timeout: 10000,
Expand Down
45 changes: 45 additions & 0 deletions test/fixtures/basic/pages/youtube-multiple.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts" setup>
const player1Ready = ref(false)
const player2Ready = ref(false)
const player3Ready = ref(false)
</script>

<template>
<div>
<div id="player1" style="width: 320px; height: 180px; margin-bottom: 10px;">
<ScriptYouTubePlayer
video-id="d_IFKP1Ofq0"
:width="320"
:height="180"
@ready="player1Ready = true"
/>
</div>
<div id="player1-status">
{{ player1Ready ? 'ready' : 'waiting' }}
</div>

<div id="player2" style="width: 320px; height: 180px; margin-bottom: 10px;">
<ScriptYouTubePlayer
video-id="dQw4w9WgXcQ"
:width="320"
:height="180"
@ready="player2Ready = true"
/>
</div>
<div id="player2-status">
{{ player2Ready ? 'ready' : 'waiting' }}
</div>

<div id="player3" style="width: 320px; height: 180px; margin-bottom: 10px;">
<ScriptYouTubePlayer
video-id="jNQXAC9IVRw"
:width="320"
:height="180"
@ready="player3Ready = true"
/>
</div>
<div id="player3-status">
{{ player3Ready ? 'ready' : 'waiting' }}
</div>
</div>
</template>
Loading