Skip to content

Stubbed components slots aren't been rendered when 2 deep. #773

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

Closed
rikbrowning opened this issue Jul 16, 2021 · 13 comments
Closed

Stubbed components slots aren't been rendered when 2 deep. #773

rikbrowning opened this issue Jul 16, 2021 · 13 comments

Comments

@rikbrowning
Copy link

I put together a test case to show what I mean. It is very difficult to describe it in words. Essentially the produced snapshot of the test is incorrect. It should include all stubbed components and their slot contents as well.

rikbrowning@53a0a13

@rikbrowning
Copy link
Author

I did some digging around the code and think I may have found the issue.

When creating a stub we are creating a render function and passing the ctx.$slots in directly. The issue here is I think that any child slots inside that stub are not getting passed through the vnodeTransform and therefore not being rendered correctly.

I changed the render function to the follow:

  const render = (ctx: ComponentPublicInstance) => {
    const slots = ctx.$slots
      ? Object.keys(ctx.$slots)
          .map((k) => ctx.$slots[k])
          .map((f) => {
            if (f) return f()
          })
      : []
    return h(tag, ctx.$props, renderStubDefaultSlot ? slots : undefined)
  }

and it started rendering out stubs inside of the first stub.

I am sure that code could be far neater, I apparently suck at writing typescript, but I think that we need to call the slot functions before passing them into the h function. Reading the examples in the vue docs they all seem to call the slot function before passing the result to the h function.

@rikbrowning
Copy link
Author

I have taken a stab a reworking renderStubDefaultSlot, the issue being that it doesnt really provide full backwards compatibility with v1.
I pushed a commit rikbrowning@d9309d4 and I am looking for feedback.

renderStubSlot can be a boolean or an object. When false nothing is rendered in the slots. When true all slots passed in are rendered. Note that slots that do not exist in the component you are stubbing will still be rendered. This may cause some false positives by rendering slots that won't actually be rendered in the browser. When an object you can specify slot property values into the slots. Essentially allowing you to stub slot prop values.

 config.renderStubSlot = {
        'component-with-slots-stub': {
          scoped: { boolean: true, string: 'test' }
        }
      }

The above config will pass in { boolean: true, string: 'test' } to the scoped slot of component-with-slots-stub.

Thoughts?

@lmiller1990
Copy link
Member

I didn't get a chance to look at the reproduction or look into this deeply yet, but

doesnt really provide full backwards compatibility with v1

Is going to be a blocker. We have renderStubDefaultSlot for this exact reason - backwards compatibility.

Issues like this come up a lot - ultimately, the purpose of shallow is to stub things. If you want a full DOM tree, I'd generally recommend using mount.

@rikbrowning
Copy link
Author

@lmiller1990 with regards to the backwards compatibility. v1 would render all slots in a stub not just the default one. Now in v2 only the default gets rendered with the renderStubDefaultSlot option. The feature would be to extend that option (I would suggest renaming) to render all provided slots in a stub.

@lmiller1990
Copy link
Member

Please give me a day or two to go over this and really understand the implications. Like you said, this is quite hard to "explain", but the code sample you've provided will help a lot. Thanks!

I try to take things to do with stubs pretty cautiously, since they've caused a few unexpected breaking changes in the past.

@xanf
Copy link
Collaborator

xanf commented Aug 1, 2021

@rikbrowning thank you for working on this!

My 2¢ here since I'm working for a month on compatibility layer between v1 and v2: I really love current behaviour and I think it is worth to keep it.

The reasoning is simple - IMO the library should not provide an easy way to shoot yourself in a leg by testing something which never appears in real world output and as you've mentioned in your commit - your approach allows user to build assertions vs non-existent slots when using renderStubSlot set to true

Users who are migrating from v1 to v2 could provide an intermediate layer (by wrapping mount) to make stubs render all slots if needed. This will be a bit more troublesome for shallowMount case, but I really think that benefits of building a safer v2 outweigh our migration pain

@rikbrowning
Copy link
Author

@xanf sorry for my delayed response.

I was thinking about this more last night and do we not already allow users to put props on stub components which the stub component might not accept? In which case how is that different from rendering slots that might not get used. In fact I would make the case that rendering all the slots allows the user to ensure that what they are actually passing through their slots matches their expectations. Now whether the stubbed component uses those slots is really up to that component. In which case using mount would be the functionality that you want not shallowMount.

If we really wanted to introduce safety we should allow configuration of slots that a stub has and render those out, thus matching reality a lot closer than only allowing rendering of the default slot.

One point I would like to call out is the documentation on upgrading from v1 https://next.vue-test-utils.vuejs.org/migration/#shallowmount-and-renderstubdefaultslot This documentation doesn't explicitly highlight that any slot other than the default will not work even when enabling the new config property. Maybe perhaps suggest the recommendation of using mount to render deeper slots?

@lmiller1990
Copy link
Member

^ Updating docs is always a good idea, if you want to make a PR improving them, that'd be great.

@rocifier
Copy link

rocifier commented Dec 6, 2023

I want to use this but can't find it anywhere in the docs. Does anyone have an example for how to use it? I tried setting config.global.renderStubDefaultSlot = true at the top of my test file and it didn't have any effect, the slot isn't rendered. I also tried it in beforeEach(). EDIT: Ugh, now I see my problem, the component doesn't use a default slot, it uses named slots. How can we achieve the same sort of automocking behaviour with named slots?

@lmiller1990
Copy link
Member

I am not sure that is supported / possible rn @rocifier, can you rework your component (or avoid the need to stub/mock)?

@robokozo
Copy link

robokozo commented Dec 6, 2023

FWIW I've found a lot of success by avoiding shallowMount/stubs features all together and instead relying on vitest/jest mocking capabilities to mock component the same way I'd mock anything else.

vi.mock("./ComponentToMock.vue", () => ({
    default: {
        template: "<article><slot name="title"></slot><slot></slot></article>",
    }
}));

@andrewbrennanfr
Copy link

@lmiller1990 with regards to the backwards compatibility. v1 would render all slots in a stub not just the default one. Now in v2 only the default gets rendered with the renderStubDefaultSlot option. The feature would be to extend that option (I would suggest renaming) to render all provided slots in a stub.

This what we are experiencing too.
Is there any movement on this issue?

Just to reconfirm:

  • v1 would render all slots directly inside the shallowMounted component
  • v2 renders none of those slots
  • v2 (with option renderStubDefaultSlot as true) the default slot is rendered. But not the others.

The docs don't indicate this is the case and it's not listed as a breaking change.

Is the suggestion really to just use mount? This isn't a 1:1 switch and there's legit reasons why now mounting all the children & running their JS isn't what you'd want.

@Kobee1203
Copy link

Here's my solution for named slots content:
https://stackblitz.com/edit/vitejs-vite-w7qqun?file=tests%2FHelloWorld.test.ts

I took my inspiration from the stubComponentsTransformer.ts file:

I used config.plugins.createStubs to override the default and resolve all stubbed component slots.

vitest.setup.ts

import { createStub } from './tests/utils/stub';
import { config } from '@vue/test-utils';
import { defineComponent } from 'vue';

config.global.renderStubDefaultSlot = true;

config.plugins.createStubs = ({ name, component }) => {
  return defineComponent({
    ...createStub(name, component),
  });
};

stub.ts (I had to copy some functions from stubComponentsTransformer.ts because they are not exposed)

import type {
  ComponentObjectPropsOptions,
  ComponentOptions,
  ComponentPropsOptions,
  ConcreteComponent,
  VNodeTypes,
} from 'vue';
import { h } from 'vue';

/**
 * @see https://github.com./vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/utils/vueShared.ts#L1
 */
const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
  const cache: Record<string, string> = Object.create(null);
  return ((str: string) => {
    const hit = cache[str];
    return hit || (cache[str] = fn(str));
  }) as any;
};

/**
 * @see https://github.com./vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/utils/vueShared.ts#L18
 */
const hyphenateRE = /\B([A-Z])/g;
const hyphenate = cacheStringFunction((str: string): string => {
  return str.replace(hyphenateRE, '-$1').toLowerCase();
});

/**
 * https://github.com./vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/utils.ts#L131
 */
const isComponent = (component: unknown): component is ConcreteComponent =>
  Boolean(
    component &&
      (typeof component === 'object' || typeof component === 'function')
  );

/**
 * @see https://github.com./vuejs/test-utils/blob/d7e3c2fb9b89592ae0db8f4806d332c8cea6c78e/src/vnodeTransformers/stubComponentsTransformer.ts#L45
 */
const normalizeStubProps = (props: ComponentPropsOptions) => {
  // props are always normalized to object syntax
  const $props = props as unknown as ComponentObjectPropsOptions;
  // eslint-disable-next-line unicorn/no-array-reduce
  return Object.keys($props).reduce((acc, key) => {
    if (typeof $props[key] === 'symbol') {
      return { ...acc, [key]: [$props[key]?.toString()] };
    }
    if (typeof $props[key] === 'function') {
      return { ...acc, [key]: ['[Function]'] };
    }
    return { ...acc, [key]: $props[key] };
  }, {});
};

type SlotProps = Record<string, Record<string, unknown>>;

export const createStub = (
  name: string,
  component: VNodeTypes,
  slotProps: SlotProps = {}
) => {
  const anonName = 'anonymous-stub';
  const tag = name ? `${hyphenate(name)}-stub` : anonName;

  const props = isComponent(component)
    ? (component as ConcreteComponent).props
    : {};

  return {
    name: name || anonName,
    props,
    setup(props, { slots }) {
      return () => {
        const stubProps = normalizeStubProps(props);
        const resolvedSlots = slots
          ? Object.keys(slots).map((k) => {
              const slot = slots[k];
              if (slot) {
                return slot(slotProps[k] ?? {});
              }
            })
          : [];
        return h(tag, stubProps, resolvedSlots);
      };
    },
  } satisfies ComponentOptions;
};

We can then use shallowMount and override some component stubs to specify slot props.

const wrapper = shallowMount(HelloWorld, {
      props: {
        msg: 'the message',
      },
      global: {
        stubs: {
          Parent: createStub('Parent', Parent, {
            child: { message: 'Slot message' },
          }),
        },
      },
    });

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

No branches or pull requests

7 participants