vikejs / vike

🔨 Flexible, lean, community-driven, dependable, fast Vite-based frontend framework.
https://vike.dev
MIT License
4.13k stars 348 forks source link

Client only components with Vue 3 #278

Closed WolfgangDrescher closed 1 year ago

WolfgangDrescher commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

I found another issue (https://github.com/egoist/vue-client-only/issues/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: https://github.com/egoist/vue-client-only/issues/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: https://github.com/vuejs/core/issues/2671

WolfgangDrescher commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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

brillout commented 2 years ago

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 commented 1 year ago

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.

https://github.com/brillout/vite-plugin-ssr/pull/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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

Updated:

Better?

KirillOlegovichH commented 1 year ago

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

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

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

@KirillOlegovichH Up for creating a full example?

KirillOlegovichH commented 1 year ago

@KirillOlegovichH Up for creating a full example?

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

brillout commented 1 year ago

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

yuanoook commented 1 year ago

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 commented 1 year ago

@yuanoook Added to the docs a7dfce543c899487c49d4e9b5138704271633089.

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

WolfgangDrescher commented 1 year ago

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 commented 1 year ago

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.