@xenterprises/nuxt-x-marketing 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -30,6 +30,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
30
  section and its sample product data were dropped with them. Every other section of the
31
31
  demo page is unchanged.
32
32
 
33
+ ## [1.5.2] - 2026-10-12
34
+
35
+ ### Fixed
36
+
37
+ - **Scroll reveal uses one long-lived shared IntersectionObserver.** It is created once
38
+ and never disconnected or rebuilt; plugin hooks (`app:mounted`, `page:finish`) and
39
+ components only scan for new reveal elements and observe them. Removes the observer
40
+ churn that left elements unrevealed when IO callbacks did not fire in time. In-viewport
41
+ elements are still marked visible synchronously, and the fallback timer remains.
42
+ XMarkHero default look is unchanged from 1.5.1 (text visible by default, new props opt-in).
43
+
33
44
  ## [1.5.1] - 2026-10-12
34
45
 
35
46
  ### Fixed
@@ -24,10 +24,12 @@ const animationSelectors = [
24
24
  '.xFadeUp-stagger',
25
25
  ].join(', ');
26
26
 
27
- // One active observer app-wide. Every init (plugin, app.vue workaround,
28
- // component) replaces the previous one, so callers never stack observers.
29
- let activeObserver = null;
27
+ // One long-lived observer shared app-wide. It is created once and never
28
+ // rebuilt: every init (plugin hooks, components, app.vue workarounds) only
29
+ // scans for new elements and observes them, so there is no observer churn.
30
+ let sharedObserver = null;
30
31
  let fallbackTimer = null;
32
+ const observed = new WeakSet();
31
33
 
32
34
  const prefersReducedMotion = () =>
33
35
  typeof window !== 'undefined' &&
@@ -41,18 +43,40 @@ const inViewport = (el) => {
41
43
  return r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw;
42
44
  };
43
45
 
44
- export const useScrollReveal = (options = {}) => {
45
- // Lifecycle hooks are only registered inside a component instance.
46
- // Outside one (e.g. a plugin) the caller drives initScrollReveal/cleanup itself.
47
- const instance = getCurrentInstance();
46
+ const revealInViewport = () => {
47
+ document.querySelectorAll(animationSelectors).forEach((el) => {
48
+ if (!el.classList.contains('is-visible') && inViewport(el)) {
49
+ el.classList.add('is-visible');
50
+ sharedObserver?.unobserve(el);
51
+ }
52
+ });
53
+ };
48
54
 
49
- const defaultOptions = {
50
- threshold: 0.15,
51
- rootMargin: '0px 0px -80px 0px',
52
- ...options,
53
- };
55
+ const getSharedObserver = () => {
56
+ if (!sharedObserver) {
57
+ sharedObserver = new IntersectionObserver((entries) => {
58
+ entries.forEach((entry) => {
59
+ if (entry.isIntersecting) {
60
+ entry.target.classList.add('is-visible');
61
+ sharedObserver?.unobserve(entry.target);
62
+ }
63
+ });
64
+ }, { threshold: 0.15, rootMargin: '0px 0px -80px 0px' });
65
+ }
66
+ return sharedObserver;
67
+ };
54
68
 
55
- const observer = ref(null);
69
+ // Test-only: drop the shared observer and pending fallback.
70
+ export const __resetScrollRevealForTests = () => {
71
+ sharedObserver = null;
72
+ if (fallbackTimer) clearTimeout(fallbackTimer);
73
+ fallbackTimer = null;
74
+ };
75
+
76
+ export const useScrollReveal = () => {
77
+ // Lifecycle hooks are only registered inside a component instance.
78
+ // Outside one (e.g. a plugin) the caller drives initScrollReveal itself.
79
+ const instance = getCurrentInstance();
56
80
 
57
81
  const initScrollReveal = () => {
58
82
  if (!import.meta.client || typeof document === 'undefined') return;
@@ -75,54 +99,31 @@ export const useScrollReveal = (options = {}) => {
75
99
  });
76
100
  root.classList.add(REVEAL_ARMED_CLASS);
77
101
 
78
- if (activeObserver && activeObserver !== observer.value) activeObserver.disconnect();
79
- if (observer.value) observer.value.disconnect();
80
-
81
- observer.value = new IntersectionObserver((entries) => {
82
- entries.forEach((entry) => {
83
- if (entry.isIntersecting) {
84
- entry.target.classList.add('is-visible');
85
- if (options.once !== false) {
86
- observer.value?.unobserve(entry.target);
87
- }
88
- } else if (options.once === false) {
89
- entry.target.classList.remove('is-visible');
90
- }
91
- });
92
- }, defaultOptions);
93
- activeObserver = observer.value;
94
-
95
- elements.forEach((el) => observer.value?.observe(el));
102
+ const io = getSharedObserver();
103
+ elements.forEach((el) => {
104
+ if (!el.classList.contains('is-visible') && !observed.has(el)) {
105
+ observed.add(el);
106
+ io.observe(el);
107
+ }
108
+ });
96
109
 
97
110
  // Fallback: if IO never fires, reveal anything on screen after a short wait.
98
111
  if (fallbackTimer) clearTimeout(fallbackTimer);
99
112
  fallbackTimer = setTimeout(() => {
100
113
  fallbackTimer = null;
101
- document.querySelectorAll(animationSelectors).forEach((el) => {
102
- if (!el.classList.contains('is-visible') && inViewport(el)) {
103
- el.classList.add('is-visible');
104
- }
105
- });
114
+ revealInViewport();
106
115
  }, REVEAL_FALLBACK_MS);
107
116
  };
108
117
 
109
- const cleanup = () => {
110
- if (observer.value) {
111
- observer.value.disconnect();
112
- if (activeObserver === observer.value) activeObserver = null;
113
- observer.value = null;
114
- }
115
- };
118
+ // The shared observer is long-lived; per-caller cleanup is a no-op.
119
+ // Detached elements are released by the WeakSet and by IO itself.
120
+ const cleanup = () => {};
116
121
 
117
122
  if (import.meta.client && instance) {
118
123
  onMounted(() => {
119
124
  // Small delay to ensure DOM is ready
120
125
  setTimeout(initScrollReveal, 100);
121
126
  });
122
-
123
- onUnmounted(() => {
124
- cleanup();
125
- });
126
127
  }
127
128
 
128
129
  return {
@@ -9,7 +9,8 @@ export default defineNuxtPlugin((nuxtApp) => {
9
9
  // instance, so drive it from app hooks: app:mounted covers initial
10
10
  // hydration (including prerendered pages, where page:finish can fire
11
11
  // before mount or not at all), page:finish covers client navigations.
12
- // initScrollReveal is idempotent and keeps one observer app-wide.
12
+ // initScrollReveal only scans and observes new elements on one shared,
13
+ // long-lived observer; it never tears down or rebuilds it.
13
14
  const { initScrollReveal } = useScrollReveal();
14
15
  const run = () => nextTick(() => initScrollReveal());
15
16
  nuxtApp.hook('app:mounted', run);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xenterprises/nuxt-x-marketing",
3
3
  "license": "SEE LICENSE IN LICENSE",
4
4
  "type": "module",
5
- "version": "1.5.1",
5
+ "version": "1.5.2",
6
6
  "main": "./nuxt.config.ts",
7
7
  "scripts": {
8
8
  "dev": "nuxi dev .playground",