Skip to content

Commit

Permalink
feat(useScriptTriggerElement): pre-hydration event triggers (#237)
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw authored Sep 3, 2024
1 parent 260eb52 commit 2faa027
Show file tree
Hide file tree
Showing 11 changed files with 136 additions and 30 deletions.
39 changes: 37 additions & 2 deletions docs/content/docs/3.api/3.use-script-trigger-element.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Create a trigger for an element to load a script based on specific element event
## Signature

```ts
function useScriptTriggerElement(options: ElementScriptTriggerOptions): Promise<void> {}
function useScriptTriggerElement(options: ElementScriptTriggerOptions): Promise<void> & { ssrAttrs?: Record<string, string> } | 'onNuxtReady' {}
```

## Arguments
Expand All @@ -22,8 +22,10 @@ function useScriptTriggerElement(options: ElementScriptTriggerOptions): Promise<
export interface ElementScriptTriggerOptions {
/**
* The event to trigger the script load.
*
* For example we can bind events that we'd normally use addEventListener for: `mousedown`, `mouseenter`, `scroll`, etc.
*/
trigger?: ElementScriptTrigger | undefined
trigger?: 'immediate' | 'visible' | string | string[] | false | undefined
/**
* The element to watch for the trigger event.
* @default document.body
Expand All @@ -36,6 +38,39 @@ export interface ElementScriptTriggerOptions {

A promise that resolves when the script is loaded.

## Handling Pre-Hydration Events

When registering a trigger that depends on user input, such as `mousedown`, it's possible that the user will interact with the element before the hydration process is complete.

In this case, the event listener will not be attached to the element, and the script will not be loaded.

To ensure this is handled correctly you should bind the `ssrAttrs` value to the element you're attaching events to. Note that you should verify
that a promise is returned from the function before using the `ssrAttrs` value.

```vue
<script setup lang="ts">
import { ref, useScriptTriggerElement } from '#imports'
const el = ref<HTMLElement>()
const trigger = useScriptTriggerElement({
trigger: 'mousedown',
el,
})
const elAttrs = computed(() => {
return {
...(trigger instanceof Promise ? trigger.ssrAttrs : {}),
}
})
</script>
<template>
<div ref="el" v-bind="elAttrs">
Click me to load the script
</div>
</template>
```


## Examples

- Load a script when an element is visible.
Expand Down
15 changes: 11 additions & 4 deletions src/runtime/components/ScriptCarbonAds.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { withQuery } from 'ufo'
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
import { onBeforeUnmount, onMounted, ref } from '#imports'
import { computed, onBeforeUnmount, onMounted, ref } from '#imports'
import type { ElementScriptTrigger } from '#nuxt-scripts'
const props = defineProps<{
Expand Down Expand Up @@ -46,12 +46,19 @@ function loadCarbon() {
carbonadsEl.value.appendChild(script)
}
const trigger = useScriptTriggerElement({ trigger: props.trigger, el: carbonadsEl })
onMounted(() => {
if (!props.trigger) {
if (trigger === 'onNuxtReady') {
loadCarbon()
}
else {
useScriptTriggerElement({ trigger: props.trigger, el: carbonadsEl })
trigger.then(loadCarbon)
}
})
const rootAttrs = computed(() => {
return {
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}
})
Expand All @@ -63,7 +70,7 @@ onBeforeUnmount(() => {
</script>

<template>
<div ref="carbonadsEl">
<div ref="carbonadsEl" v-bind="rootAttrs">
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
<slot v-else-if="status === 'loading'" name="loading" />
<slot v-else-if="status === 'error'" name="error" />
Expand Down
9 changes: 8 additions & 1 deletion src/runtime/components/ScriptCrisp.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
import { useScriptCrisp } from '../registry/crisp'
import { ref, onMounted, onBeforeUnmount, watch } from '#imports'
import { ref, onMounted, onBeforeUnmount, watch, computed } from '#imports'
import type { ElementScriptTrigger } from '#nuxt-scripts'
const props = withDefaults(defineProps<{
Expand Down Expand Up @@ -72,12 +72,19 @@ onMounted(() => {
onBeforeUnmount(() => {
observer?.disconnect()
})
const rootAttrs = computed(() => {
return {
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}
})
</script>

<template>
<div
ref="rootEl"
:style="{ display: isReady ? 'none' : 'block' }"
v-bind="rootAttrs"
>
<slot :ready="isReady" />
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
Expand Down
10 changes: 8 additions & 2 deletions src/runtime/components/ScriptGoogleAdsense.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
import { useScriptGoogleAdsense } from '../registry/google-adsense'
import { callOnce, onMounted, ref, watch } from '#imports'
import { callOnce, computed, onMounted, ref, watch } from '#imports'
import type { ElementScriptTrigger } from '#nuxt-scripts'
const props = withDefaults(defineProps<{
Expand Down Expand Up @@ -49,6 +49,12 @@ onMounted(() => {
}
})
})
const rootAttrs = computed(() => {
return {
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}
})
</script>

<template>
Expand All @@ -60,7 +66,7 @@ onMounted(() => {
:data-ad-slot="dataAdSlot"
:data-ad-format="dataAdFormat"
:data-full-width-responsive="dataFullWidthResponsive"
v-bind="{ ...$attrs }"
v-bind="rootAttrs"
>
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
<slot v-else-if="status === 'loading'" name="loading" />
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/components/ScriptGoogleMaps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ const mapEl = ref<HTMLElement>()
const centerOverride = ref()
const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl })
const { load, status, onLoaded } = useScriptGoogleMaps({
apiKey: props.apiKey,
scriptOptions: {
trigger: useScriptTriggerElement({ trigger: props.trigger, el: rootEl }),
trigger,
},
})
Expand Down Expand Up @@ -399,6 +400,7 @@ const rootAttrs = computed(() => {
height: `'auto'`,
aspectRatio: `${props.width}/${props.height}`,
},
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}) as HTMLAttributes
})
Expand Down
19 changes: 13 additions & 6 deletions src/runtime/components/ScriptIntercom.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useScriptIntercom } from '../registry/intercom'
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
import { ref, onMounted, watch, onBeforeUnmount } from '#imports'
import { ref, onMounted, watch, onBeforeUnmount, computed } from '#imports'
import type { ElementScriptTrigger } from '#nuxt-scripts'
const props = withDefaults(defineProps<{
Expand Down Expand Up @@ -77,16 +77,23 @@ onMounted(() => {
onBeforeUnmount(() => {
observer?.disconnect()
})
const rootAttrs = computed(() => {
return {
style: {
display: isReady.value ? 'none' : 'block',
bottom: `${props.verticalPadding || 20}px`,
[props.alignment || 'right']: `${props.horizontalPadding || 20}px`,
},
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}
})
</script>

<template>
<div
ref="rootEl"
:style="{
display: isReady ? 'none' : 'block',
bottom: `${verticalPadding || 20}px`,
[alignment || 'right']: `${horizontalPadding || 20}px`,
}"
v-bind="rootAttrs"
>
<slot :ready="isReady" />
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
Expand Down
13 changes: 10 additions & 3 deletions src/runtime/components/ScriptLemonSqueezy.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ElementScriptTrigger } from '../types'
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
import { useScriptLemonSqueezy } from '../registry/lemon-squeezy'
import type { LemonSqueezyEventPayload } from '../registry/lemon-squeezy'
import { onMounted, ref } from '#imports'
import { computed, onMounted, ref } from '#imports'
const props = withDefaults(defineProps<{
trigger?: ElementScriptTrigger
Expand All @@ -17,9 +17,10 @@ const emits = defineEmits<{
}>()
const rootEl = ref<HTMLElement | null>(null)
const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl })
const instance = useScriptLemonSqueezy({
scriptOptions: {
trigger: useScriptTriggerElement({ trigger: props.trigger, el: rootEl }),
trigger,
},
})
onMounted(() => {
Expand All @@ -36,10 +37,16 @@ onMounted(() => {
emits('ready', instance)
})
})
const rootAttrs = computed(() => {
return {
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}
})
</script>

<template>
<div ref="rootEl">
<div ref="rootEl" v-bind="rootAttrs">
<slot />
</div>
</template>
13 changes: 10 additions & 3 deletions src/runtime/components/ScriptStripePricingTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ref } from 'vue'
import type { ElementScriptTrigger } from '../types'
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
import { useScript } from '../composables/useScript'
import { onBeforeUnmount, onMounted, watch } from '#imports'
import { computed, onBeforeUnmount, onMounted, watch } from '#imports'
const props = withDefaults(defineProps<{
trigger?: ElementScriptTrigger
Expand All @@ -23,8 +23,9 @@ const emit = defineEmits<{
const rootEl = ref<HTMLDivElement | undefined>()
const containerEl = ref<HTMLDivElement | undefined>()
const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl })
const instance = useScript(`https://js.stripe.com/v3/pricing-table.js`, {
trigger: useScriptTriggerElement({ trigger: props.trigger, el: rootEl }),
trigger,
})
const { onLoaded, status } = instance
Expand Down Expand Up @@ -55,10 +56,16 @@ onMounted(() => {
onBeforeUnmount(() => {
pricingTable.value?.remove()
})
const rootAttrs = computed(() => {
return {
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}
})
</script>

<template>
<div ref="rootEl">
<div ref="rootEl" v-bind="rootAttrs">
<div ref="containerEl" />
<slot v-if="status === 'loading'" name="loading" />
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
Expand Down
1 change: 1 addition & 0 deletions src/runtime/components/ScriptVimeoPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const rootAttrs = computed(() => {
position: 'relative',
backgroundColor: 'black',
},
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}) as HTMLAttributes
})
Expand Down
1 change: 1 addition & 0 deletions src/runtime/components/ScriptYouTubePlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const rootAttrs = computed(() => {
height: `'auto'`,
aspectRatio: `${props.width}/${props.height}`,
},
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}) as HTMLAttributes
})
Expand Down
42 changes: 34 additions & 8 deletions src/runtime/composables/useScriptTriggerElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
useEventListener,
useIntersectionObserver,
} from '@vueuse/core'
import { tryOnScopeDispose } from '@vueuse/shared'
import { tryOnScopeDispose, tryOnMounted } from '@vueuse/shared'
import { watch } from 'vue'
import type { ElementScriptTrigger } from '../types'

export interface ElementScriptTriggerOptions {
Expand Down Expand Up @@ -52,29 +53,54 @@ function useElementVisibilityPromise(element: MaybeComputedElementRef) {
/**
* Create a trigger for an element to load a script based on specific element events.
*/
export function useScriptTriggerElement(options: ElementScriptTriggerOptions): Promise<void> | 'onNuxtReady' {
export function useScriptTriggerElement(options: ElementScriptTriggerOptions): Promise<void> & { ssrAttrs?: Record<string, string> } | 'onNuxtReady' {
const { el, trigger } = options
const triggers = (Array.isArray(options.trigger) ? options.trigger : [options.trigger]).filter(Boolean) as string[]
if (!trigger || triggers.includes('immediate') || triggers.includes('onNuxtReady')) {
return 'onNuxtReady'
}
if (import.meta.server || !el)
return new Promise<void>(() => {})
if (triggers.some(t => ['visibility', 'visible'].includes(t)))
return useElementVisibilityPromise(el)
if (triggers.some(t => ['visibility', 'visible'].includes(t))) {
if (import.meta.server || !el)
return new Promise<void>(() => {})
// TODO optimize this, only have 1 instance of intersection observer, stop on find
return new Promise<void>((resolve, reject) => {
return useElementVisibilityPromise(el)
}
const ssrAttrs: Record<string, string> = {}
if (import.meta.server) {
triggers.forEach((trigger) => {
ssrAttrs[`on${trigger}`] = `this.dataset.script_${trigger} = true`
})
}
const p = new Promise<void>((resolve, reject) => {
const target = typeof el !== 'undefined' ? (el as EventTarget) : document.body
const _ = useEventListener(
typeof el !== 'undefined' ? (el as EventTarget) : document.body,
target,
triggers,
() => {
_()
resolve()
},
{ once: true, passive: true },
)
tryOnMounted(() => {
// check if target has any of the triggers active onthe data set
const _2 = watch(target, ($el) => {
if ($el) {
triggers.forEach((trigger) => {
if (($el as HTMLElement).dataset[`script_${trigger}`]) {
_()
_2()
resolve()
}
})
}
}, {
immediate: true,
})
})
tryOnScopeDispose(reject)
}).catch(() => {
// it's okay
})
return Object.assign(p, { ssrAttrs })
}

0 comments on commit 2faa027

Please sign in to comment.