diff --git a/.claude/plans/543-plausible-broken.md b/.claude/plans/543-plausible-broken.md new file mode 100644 index 00000000..ea85c8ed --- /dev/null +++ b/.claude/plans/543-plausible-broken.md @@ -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 diff --git a/docs/content/scripts/content/youtube-player.md b/docs/content/scripts/content/youtube-player.md index ed41cb06..b820f88e 100644 --- a/docs/content/scripts/content/youtube-player.md +++ b/docs/content/scripts/content/youtube-player.md @@ -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. @@ -92,6 +93,7 @@ export interface YouTubeProps { playerVars?: YT.PlayerVars width?: number height?: number + placeholderObjectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' } ``` @@ -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] }>() ``` diff --git a/src/runtime/components/ScriptYouTubePlayer.vue b/src/runtime/components/ScriptYouTubePlayer.vue index d1785cb9..b9434a21 100644 --- a/src/runtime/components/ScriptYouTubePlayer.vue +++ b/src/runtime/components/ScriptYouTubePlayer.vue @@ -3,7 +3,7 @@ // @ts-nocheck /// -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' @@ -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', @@ -53,6 +59,7 @@ const props = withDefaults(defineProps<{ playerVars: { autoplay: 0, playsinline: 1 }, width: 640, height: 360, + placeholderObjectFit: 'cover', }) const emits = defineEmits<{ @@ -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', @@ -73,25 +81,63 @@ 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 = 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((resolve) => { if (typeof YT.Player === 'undefined') @@ -99,9 +145,16 @@ onMounted(() => { 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() @@ -109,13 +162,10 @@ onMounted(() => { 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) - }) } }])), }) @@ -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, @@ -186,7 +234,7 @@ const placeholderAttrs = computed(() => { loading: props.aboveTheFold ? 'eager' : 'lazy', style: { width: '100%', - objectFit: 'contain', + objectFit: props.placeholderObjectFit, height: '100%', }, onLoad(payload) { diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 50437349..6613216e 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -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, diff --git a/test/fixtures/basic/pages/youtube-multiple.vue b/test/fixtures/basic/pages/youtube-multiple.vue new file mode 100644 index 00000000..2badd9f7 --- /dev/null +++ b/test/fixtures/basic/pages/youtube-multiple.vue @@ -0,0 +1,45 @@ + + +