Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client only components with Vue 3 #278

Closed
WolfgangDrescher opened this issue Mar 14, 2022 · 18 comments
Closed

Client only components with Vue 3 #278

WolfgangDrescher opened this issue Mar 14, 2022 · 18 comments

Comments

@WolfgangDrescher
Copy link

WolfgangDrescher commented Mar 14, 2022

I wrote a little component that includes third party packages that cannot be rendered server side (midi-player-js and soundfont-player; they are using AudioContext and XMLHttpRequest). Following the docs the best way to solve this problem would be to use dynamic imports. So shouldn't it be enough to just use defineAsyncComponent because Vue wraps the component into a Promise?

<script setup>
// pages/song.page.vue
const MidiPlayer = defineAsyncComponent(() => import('../../components/MidiPlayer.vue'));
</script>

<template>
    <MidiPlayer />
</template>

But if I use it like this the server still tries to render the component and throws XMLHttpRequest is not defined when running npm run dev. With a condition on the component to only render it in browser it still failes with AudioContext is not defined since the component gets loaded in SSR even if it's not displayed in the template.

<script setup>
// pages/song.page.vue
const isBrowser = typeof window !== 'undefined';
const MidiPlayer = defineAsyncComponent(() => import('../../components/MidiPlayer.vue'));
</script>

<template>
    <MidiPlayer v-if="isBrowser" />
</template>

So I wrote a litte <ClientOnly> component:

// components/ClientOnly.js
import { h } from 'vue';

const isBrowser = typeof window !== 'undefined';

export default {
    setup(props, { slots }) {
        const slot = slots.default ? slots.default() : [];
        return () => (isBrowser ? h('div', {}, [slot]) : h('div'));
    },
};

This seems to work better but I get an error: Hydration completed but contains mismatches. Is there a way to prevent this from happening?

At the end the best way to do this for me was to add an additional component AsyncMidiPlayer:

// components/AsyncMidiPlayer.js
import { defineAsyncComponent, h } from 'vue';

const isBrowser = typeof window !== 'undefined';

const MidiPlayer = defineAsyncComponent(() => import('./MidiPlayer.vue'));

export default {
    setup(props, context) {
        return () => isBrowser ? h(MidiPlayer, {...props,...context.attrs,}): h('div');
    },
};

And to use it like this:

<script setup>
// pages/song.page.vue
import AsyncMidiPlayer from '../components/AsyncMidiPlayer.js';
</script>

<template>
    <AsyncMidiPlayer  />
</template>

But since I have multiple components that cannot be interpreted server side I would like to have a better solution than writing a custom wrapper for each of them. I was not able to create a generic version of this because the import source needs to be specific (I ran into The above dynamic import cannot be analyzed by vite.). I'm sure there is a better solution for this. Did anyone find a better solution to this than me? It would be nice to extend the vite-plugin-ssr docs with a working vue 3 example.

@WolfgangDrescher
Copy link
Author

I just came up with another idea:

// composables/useClientOnly.js
import { h, defineComponent } from 'vue';

const isBrowser = typeof window !== 'undefined';

export function useClientOnly(asyncComponent) {
    return defineComponent({
        setup(props, context) {
            return () => isBrowser ? h(asyncComponent, { ...props, ...context.attrs }) : h('div');
        }
    });
};

And use it on the page like this:

<script setup>
// pages/song.page.vue
import { useClientOnly } from '../../composables/useClientOnly';
const MidiPlayer = defineAsyncComponent(() => import('../../components/MidiPlayer.vue'));
const AsyncMidiPlayer = useClientOnly(MidiPlayer);
</script>

<template>
    <AsyncMidiPlayer />
</template>

Still I think that this is not the optimal solution for this problem.

@WolfgangDrescher
Copy link
Author

I found another issue (egoist/vue-client-only#122) that pointed me to the current ClientOnly component from Nuxt.js version 3:

https://github.com/nuxt/framework/blob/4e9a27257bdfae65403ea73116d8c5508b642f44/packages/nuxt3/src/app/components/client-only.mjs

<script setup>
// pages/song.vue
import { ref, onMounted, nextTick } from 'vue';
import ClientOnly from '../components/ClientOnly.js';
import MidiPlayer from '../components/MidiPlayer.vue';

const midiPlayer = ref(null);

onMounted(() => {
    nextTick(async () => { 
        console.log(midiPlayer.value);
    });
});
</script>

<template>
    <ClientOnly>
        <MidiPlayer ref="midiPlayer" />
    </ClientOnly>
</template>

In my case it is important that I get access to the component as template ref to call an exposed method. So I really would prefer not to use an async component but instead a regular component in combination with a ClientOnly wrapper. Note that here I also need to use nextTick to get the template reference (see: egoist/vue-client-only#50).

If I run vite build && vite build --ssr && vite-plugin-ssr prerender everything is perfectly fine and works finally as expected. But I still get an error when running node ./server/index.js:

[vite] Error when evaluating SSR module /node_modules/verovio-humdrum/index.js?v=2f51debf:
ReferenceError: __dirname is not defined

Any idea why it works fine with vite build but not with the development server?

This issue also is somewhat related when I tried to get it work with the useClientOnly.js composable that I posted before: vuejs/core#2671

@WolfgangDrescher
Copy link
Author

NB: My package.json setup or the vite-plugin-ssr prerender seem to be somewhat relevant for this. I simplified the examples above. But in my current setup I linked a plugin that I'm working on at the same time as file:../../vue-verovio-canvas in package.json:

{
    "dependencies": {
        "vue-verovio-canvas": "file:../../vue-verovio-canvas"
    }
}

Running vite-plugin-ssr prerender I get another error:

vite-plugin-ssr 0.3.59 pre-rendering HTML...
Error: Cannot find module 'verovio-humdrum'

But if I change this dependency to the GitHub repository instead of the local file link everything works just fine again:

{
    "dependencies": {
        "vue-verovio-canvas": "github:WolfgangDrescher/vue-verovio-canvas"
    }
}

The development server is still not running tough and still prints the same error as mentioned before:

[vite] Error when evaluating SSR module /node_modules/verovio-humdrum/index.js?v=2f51debf:
ReferenceError: __dirname is not defined

@brillout
Copy link
Member

brillout commented Mar 14, 2022

Most(/all?) seem to be user land issues.

But I agree a working example would be nice.

One thing you may want to try is import.meta.env.SSR, see https://vitejs.dev/guide/ssr.html#conditional-logic. That should ensure that SSR will not consider your component at all.

@DoubleJ-G
Copy link

I've used the ClientOnly component from vitepress with no issues ClientOnly.ts

@brillout
Copy link
Member

https://vitepress.vuejs.org/guide/global-component.html#clientonly

Thanks @DoubleJ-G.

I will update the docs. I'm leaving this open until I do.

@NoonRightsWarriorBehindHovering

A similar issue i found far quicker though was a similar issue for react though.
As such im linking to the PR for better observability.

#488

A bit off topic:
I was about to open an issue myself for the lack of documentation. By sheer luck i found this issue though. Guess my search queries about dynamic imports were far off :).
I really enjoy vps a lot, albeit sometimes the documentation is too broad and concrete knowledge is documented on different pages (Especially irt the default Server // vite cli in combination with moving to ES6).

I will try the vitepress component soon. Thanks all involved!

@brillout
Copy link
Member

I don't see the lack of documentation: I think https://vite-plugin-ssr.com/client-only-components and https://vite-plugin-ssr.com/dynamic-import covers it.

@NoonRightsWarriorBehindHovering

I'm unsure about my ability to provide a PR soon, but the current Statement of
UI Frameworks usually don't execute import() upon SSR (this is, for example, the case with React and Vue).
is untrue, as the code will be loaded in SSR mode. Especially, because the default renderer includes rendering everything to a string.

Using the Client Only Component does fix that issue though, but may make migration of an existing app (using a similar, but inferior Implementation of the renderer) somewhat more painful than these statements may make one believe.
It's by no means challenging.

Which is why, i linked the above issues together for better visibility :)

@brillout
Copy link
Member

Updated:

Better?

@KirillOlegovichH
Copy link

KirillOlegovichH commented Mar 20, 2023

Вариант решения для Quasar 2, vue 3

<template>
  <q-no-ssr> <!-- Важно -->
        <YaMap />
  </q-no-ssr>
</template>

<script>
   components: {
    YaMap: defineAsyncComponent(() =>
      import('components/modules/YaMap.vue') /* Важно */
    )
  },
</script>

@brillout
Copy link
Member

@KirillOlegovichH Up for creating a full example?

@KirillOlegovichH
Copy link

@KirillOlegovichH Up for creating a full example?

Обновил ответ. Пример применим только для Quasar framework 2. Прошу прощение что не уточнил сразу

@brillout
Copy link
Member

brillout commented May 31, 2023

Closing as it's mostly done. Contribution welcome to create a repository example.

@yuanoook
Copy link

yuanoook commented Jun 19, 2023

I don't see the lack of documentation: I think https://vite-plugin-ssr.com/client-only-components and https://vite-plugin-ssr.com/dynamic-import covers it.

A simple ClientOnly.vue can do the trick

<template>
  <template v-if="isMounted"><slot /></template>
  <template v-else><slot name="placeholder" /></template>
</template>

<script setup>
import { ref, onMounted } from 'vue'
const isMounted = ref(false)
onMounted(() => {
  isMounted.value = true
})
</script>

and here's the one with Suspense if you have async setup in you component

<template>
  <template v-if="isMounted">
    <Suspense>
      <slot />
    </Suspense>
  </template>
  <template v-else><slot name="placeholder" /></template>
</template>

<script setup>
import { ref, onMounted, Suspense } from 'vue'
const isMounted = ref(false)
onMounted(() => {
  isMounted.value = true
})
</script>

Here's how to use ClientOnly.vue

<template>
  <ClientOnly>
    <OnlyRenderOnClientComponent v-bind="someProps"/>
    <template #placeholder>This is for SSR/SEO</template>
  </ClientOnly>
</template>

@brillout
Copy link
Member

@yuanoook Added to the docs a7dfce5.

@WolfgangDrescher What did you end up with? I'm looking for a <ClientOnly> implementation that uses defineAsyncComponent().

@WolfgangDrescher
Copy link
Author

Once Nuxt 3 was ready for production I started migrating to it. So I do not have an example for you with defineAsyncComponent, sorry.

@apappas1129
Copy link

Reply to:

I was excited to use this component as it makes so much sense and straight forward. I wrapped my HJ29/vue3-tabs as so:

      <ClientOnly>
        <Tabs v-if="!ssr" v-model="selectedTab">
          <Tab :val="'subject'" :label="'Subject'" :indicator="true"></Tab>
          <Tab :val="'courses'" :label="'Courses'" :indicator="true"></Tab>
        </Tabs>
        <TabPanels v-model="selectedTab" :animate="true">
          <TabPanel :val="'subject'">
            <form @submit.prevent="onSubmit()">
              <p>Make subject form</p>
            </form>
          </TabPanel>
          <TabPanel :val="'courses'">
            <p>List courses</p>
          </TabPanel>
        </TabPanels>
        <template #placeholder>
          <p>This is for SSR/SEO</p>
        </template>
      </ClientOnly>

but unfortunately for some reason, the Tabs component (which reads document) is still run on server side despite the ClientOnly wrapping it which should prevent that from happening. BUT, if I comment out the whole thing and add some static html code to save and render on live reload, then uncomment, save and live reload, it magically works temporarily until you refresh the page. I don't know what's going on and I have no Idea how to debug it.

For now what I did for my vue component is utilize the *page.client.vue way of doing things. But this also presented an issue for me on my createSSRApp where I used vue markRaw. I get this Error: Object.defineProperty called on non-object and I happen to fix it with lodash:

 Page: _.isObject(pageContext.Page) ? markRaw(pageContext.Page) : pageContext.Page,

Pretty sure this is not the optimal/correct way of doing it but for now it works. Though I'm not happy with the warning sign of not having markRaw.

Here is the full context of the code. Need your insights on this:

async function onBeforeRender(pageContext: PageContextServer) {
  const { app, store } = createApp(pageContext);

  let err: unknown;
  // Workaround: renderToString_() swallows errors in production, see https://github.com/vuejs/core/issues/7876
  app.config.errorHandler = err_ => {
    err = err_;
  };

  // Reference: https://vite-plugin-ssr.com/stream
  const stream = renderToNodeStream(app);

  const initialStoreState = store.state.value;

  if (err) throw err;

  return {
    pageContext: {
      initialStoreState,
      stream,
    },
  };
}
import { App, createSSRApp, h, reactive, markRaw } from 'vue';

import { createMongoAbility } from '@casl/ability';
import { unpackRules } from '@casl/ability/extra';
import { abilitiesPlugin as casl } from '@casl/vue';

import { createPinia } from 'pinia';

import { setPageContext } from './usePageContext';
import { PageContext } from './types';

import { GuestLayout, InstructorLayout, StudentLayout } from '#root/layouts/index';
import _ from 'lodash';

export { createApp };

interface AppPageElement extends App<Element> {
  changePage: (pageContext: PageContext) => void;
}

function createApp(pageContext: PageContext) {
  let rootComponentContext: PageContext;
  console.log('TEST mark', pageContext.Page)
  const app = createSSRApp({
    data: () => ({
      Page: _.isObject(pageContext.Page) ? markRaw(pageContext.Page) : pageContext.Page,
      pageProps: markRaw(pageContext.pageProps || {}),
      Layout: markRaw(pageContext.exports.Layout || selectLayout(pageContext)),
    }),
    render() {
      const renderLayoutSlot = () => h(this.Page, this.pageProps || {});
      return h(this.Layout, {}, { default: renderLayoutSlot });
    },
    created() {
      rootComponentContext = this;
    },
  }) as AppPageElement;

  const store = createPinia();
  app.use(store);

  if (pageContext.ability) {
    try {
      const unpackedRules = unpackRules(pageContext.ability);
      const ability = createMongoAbility(unpackedRules as any); // FIXME: lazy bypass
      app.use(casl, ability, {
        useGlobalProperties: true,
      });
    } catch (e) {
      console.error('Failed to unpack ability. Error:\n', e);
    }
  }

  // We use `app.changePage()` to do Client Routing, see `_default.page.client.js`
  Object.assign(app, {
    changePage: (pageContext: PageContext) => {
      Object.assign(pageContextReactive, pageContext);
      rootComponentContext.Page = markRaw(pageContext.Page);
      rootComponentContext.pageProps = markRaw(pageContext.pageProps || {});
    },
  });

  // When doing Client Routing, we mutate pageContext (see usage of `app.changePage()` in `_default.page.client.js`).
  // We therefore use a reactive pageContext.
  const pageContextReactive = reactive(pageContext);

  setPageContext(app, pageContextReactive);

  return { app, store };
}

function selectLayout(pageContext: PageContext) {
  switch (pageContext.user?.role) {
    case 'instructor':
      return InstructorLayout;
    case 'student':
      return StudentLayout;
    default:
      return GuestLayout;
  }
}

I am not sure how to also add what to render on server side for SEO similar to the feature of the ClientOnly component on the #placeholder slot. I tried adding an index.page.server.vue alongside the index.page.client.vue but what it does is that it ignores the .client.vue component entirely and just uses the other one.

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

No branches or pull requests

7 participants