mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
feat(monorepo): migrate vue packages and apply oxlint refactors
This commit is contained in:
1
vue/primitives/src/env.d.ts
vendored
Normal file
1
vue/primitives/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare const __DEV__: boolean;
|
||||
1
vue/primitives/src/index.ts
Normal file
1
vue/primitives/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './primitive';
|
||||
22
vue/primitives/src/primitive/Primitive.ts
Normal file
22
vue/primitives/src/primitive/Primitive.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Component, IntrinsicElementAttributes, SetupContext } from 'vue';
|
||||
import { h } from 'vue';
|
||||
import { Slot } from './Slot';
|
||||
|
||||
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
||||
|
||||
export interface PrimitiveProps {
|
||||
as?: keyof IntrinsicElementAttributes | Component;
|
||||
}
|
||||
|
||||
export function Primitive(props: PrimitiveProps, ctx: FunctionalComponentContext) {
|
||||
return props.as === 'template'
|
||||
? h(Slot, ctx.attrs, ctx.slots)
|
||||
: h(props.as!, ctx.attrs, ctx.slots);
|
||||
}
|
||||
|
||||
Primitive.props = {
|
||||
as: {
|
||||
type: [String, Object],
|
||||
default: 'div' as const,
|
||||
},
|
||||
};
|
||||
29
vue/primitives/src/primitive/Slot.ts
Normal file
29
vue/primitives/src/primitive/Slot.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { SetupContext } from 'vue';
|
||||
import { cloneVNode, warn } from 'vue';
|
||||
import { getRawChildren } from '../utils/getRawChildren';
|
||||
|
||||
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
||||
|
||||
/**
|
||||
* A component that renders a single child from its default slot,
|
||||
* applying the provided attributes to it.
|
||||
*
|
||||
* @param _ - Props (unused)
|
||||
* @param context - Setup context containing slots and attrs
|
||||
* @returns Cloned VNode with merged attrs or null
|
||||
*/
|
||||
export function Slot(_: Record<string, unknown>, { slots, attrs }: FunctionalComponentContext) {
|
||||
if (!slots.default) return null;
|
||||
|
||||
const children = getRawChildren(slots.default());
|
||||
|
||||
if (!children.length) return null;
|
||||
|
||||
if (__DEV__ && children.length > 1) {
|
||||
warn('<Slot> can only be used on a single element or component.');
|
||||
}
|
||||
|
||||
return cloneVNode(children[0]!, attrs, true);
|
||||
}
|
||||
|
||||
Slot.inheritAttrs = false;
|
||||
116
vue/primitives/src/primitive/__test__/Primitive.bench.ts
Normal file
116
vue/primitives/src/primitive/__test__/Primitive.bench.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { cloneVNode, Comment, createVNode, h } from 'vue';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
// -- Attribute sets of increasing size --
|
||||
|
||||
const attrs1 = { class: 'a' };
|
||||
|
||||
const attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
|
||||
|
||||
const attrs15 = {
|
||||
'class': 'a',
|
||||
'id': 'b',
|
||||
'style': { color: 'red' },
|
||||
'onClick': () => {},
|
||||
'role': 'button',
|
||||
'tabindex': '0',
|
||||
'title': 'tip',
|
||||
'data-a': '1',
|
||||
'data-b': '2',
|
||||
'data-c': '3',
|
||||
'data-d': '4',
|
||||
'data-e': '5',
|
||||
'data-f': '6',
|
||||
'data-g': '7',
|
||||
'data-h': '8',
|
||||
};
|
||||
|
||||
const defaultSlot = { default: () => [h('span', 'content')] };
|
||||
const noop = () => {};
|
||||
|
||||
// ---- Baselines (raw Vue calls) ----
|
||||
|
||||
describe('baseline: raw h()', () => {
|
||||
bench('h() — 1 attr', () => {
|
||||
h('div', attrs1, defaultSlot);
|
||||
});
|
||||
|
||||
bench('h() — 5 attrs', () => {
|
||||
h('div', attrs5, defaultSlot);
|
||||
});
|
||||
|
||||
bench('h() — 15 attrs', () => {
|
||||
h('div', attrs15, defaultSlot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline: raw cloneVNode()', () => {
|
||||
const child = h('div', 'content');
|
||||
|
||||
bench('cloneVNode — 1 attr', () => {
|
||||
cloneVNode(child, attrs1, true);
|
||||
});
|
||||
|
||||
bench('cloneVNode — 5 attrs', () => {
|
||||
cloneVNode(child, attrs5, true);
|
||||
});
|
||||
|
||||
bench('cloneVNode — 15 attrs', () => {
|
||||
cloneVNode(child, attrs15, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Primitive overhead vs raw h() ----
|
||||
|
||||
describe('Primitive vs h()', () => {
|
||||
bench('h("div") — baseline', () => {
|
||||
h('div', attrs5, defaultSlot);
|
||||
});
|
||||
|
||||
bench('Primitive({ as: "div" })', () => {
|
||||
Primitive({ as: 'div' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
|
||||
bench('Primitive({ as: "template" }) — Slot mode', () => {
|
||||
Primitive({ as: 'template' }, { attrs: attrs5, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Slot scaling by attribute count ----
|
||||
|
||||
describe('Slot — scaling by attrs', () => {
|
||||
bench('1 attr', () => {
|
||||
Slot({} as never, { attrs: attrs1, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
|
||||
bench('5 attrs', () => {
|
||||
Slot({} as never, { attrs: attrs5, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
|
||||
bench('15 attrs (mixed types)', () => {
|
||||
Slot({} as never, { attrs: attrs15, slots: defaultSlot, emit: noop });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Slot edge cases ----
|
||||
|
||||
describe('Slot — edge cases', () => {
|
||||
bench('child with comments to skip', () => {
|
||||
Slot({} as never, {
|
||||
attrs: attrs5,
|
||||
slots: {
|
||||
default: () => [
|
||||
createVNode(Comment, null, 'skip'),
|
||||
createVNode(Comment, null, 'skip'),
|
||||
h('span', 'content'),
|
||||
],
|
||||
},
|
||||
emit: noop,
|
||||
});
|
||||
});
|
||||
|
||||
bench('no default slot', () => {
|
||||
Slot({} as never, { attrs: attrs5, slots: {}, emit: noop });
|
||||
});
|
||||
});
|
||||
482
vue/primitives/src/primitive/__test__/Primitive.test.ts
Normal file
482
vue/primitives/src/primitive/__test__/Primitive.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import type { PrimitiveProps } from '..';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createVNode, Comment, h, defineComponent, markRaw, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
// --- Slot ---
|
||||
|
||||
describe(Slot, () => {
|
||||
it('returns null when no default slot is provided', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(Slot);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders the first valid child from the slot', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(Slot, null, { default: () => [h('span', 'hello')] });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('<span>hello</span>');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('applies attrs to the slotted child', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, { class: 'custom', id: 'test' }, { default: () => [h('div')] });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.find('div').classes()).toContain('custom');
|
||||
expect(wrapper.find('div').attributes('id')).toBe('test');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('skips Comment nodes and picks the first element', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, null, {
|
||||
default: () => [createVNode(Comment, null, 'skip'), h('em', 'content')],
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('<em>content</em>');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('warns in DEV mode when multiple valid children are provided', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, null, {
|
||||
default: () => [h('div', 'a'), h('span', 'b')],
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls.some(args =>
|
||||
args.some(arg => typeof arg === 'string' && arg.includes('<Slot>')),
|
||||
)).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders null when slot has only comments', () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Slot, null, {
|
||||
default: () => [
|
||||
createVNode(Comment, null, 'a'),
|
||||
createVNode(Comment, null, 'b'),
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Primitive ---
|
||||
|
||||
describe(Primitive, () => {
|
||||
it('renders a div by default', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
slots: { default: () => 'content' },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('DIV');
|
||||
expect(wrapper.text()).toBe('content');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders the element specified by "as" prop', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'button' },
|
||||
slots: { default: () => 'click me' },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('BUTTON');
|
||||
expect(wrapper.text()).toBe('click me');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders a span element', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'span' },
|
||||
slots: { default: () => 'text' },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SPAN');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('passes attributes to the rendered element', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'input' },
|
||||
attrs: { type: 'text', placeholder: 'enter' },
|
||||
});
|
||||
|
||||
expect(wrapper.attributes('type')).toBe('text');
|
||||
expect(wrapper.attributes('placeholder')).toBe('enter');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('passes class and style attributes', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'div' },
|
||||
attrs: { class: 'my-class', style: 'color: red' },
|
||||
slots: { default: () => 'styled' },
|
||||
});
|
||||
|
||||
expect(wrapper.classes()).toContain('my-class');
|
||||
expect(wrapper.attributes('style')).toBe('color: red;');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards event listeners', async () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'button' },
|
||||
attrs: { onClick },
|
||||
slots: { default: () => 'click' },
|
||||
});
|
||||
|
||||
await wrapper.trigger('click');
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders a custom Vue component via "as"', () => {
|
||||
const Custom = markRaw(defineComponent({
|
||||
props: { label: String },
|
||||
setup(props) {
|
||||
return () => h('span', { class: 'custom' }, props.label);
|
||||
},
|
||||
}));
|
||||
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: Custom },
|
||||
attrs: { label: 'hello' },
|
||||
});
|
||||
|
||||
expect(wrapper.find('.custom').exists()).toBe(true);
|
||||
expect(wrapper.text()).toBe('hello');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders in Slot mode when as="template"', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
slots: { default: () => h('section', 'slot content') },
|
||||
});
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SECTION');
|
||||
expect(wrapper.text()).toBe('slot content');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('merges attrs onto the slotted child in template mode', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
attrs: { 'class': 'merged', 'data-testid': 'slot' },
|
||||
slots: { default: () => h('div', 'child') },
|
||||
});
|
||||
|
||||
expect(wrapper.classes()).toContain('merged');
|
||||
expect(wrapper.attributes('data-testid')).toBe('slot');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards event listeners in template mode', async () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
attrs: { onClick },
|
||||
slots: { default: () => h('button', 'click me') },
|
||||
});
|
||||
|
||||
await wrapper.trigger('click');
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders empty when template mode has no slot', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('can switch element via reactive "as" prop', async () => {
|
||||
const Wrapper = defineComponent({
|
||||
props: { tag: { type: String, default: 'div' } },
|
||||
setup(props) {
|
||||
return () => h(Primitive, { as: props.tag as PrimitiveProps['as'] }, { default: () => 'test' });
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(Wrapper, { props: { tag: 'div' } });
|
||||
|
||||
expect(wrapper.element.tagName).toBe('DIV');
|
||||
|
||||
await wrapper.setProps({ tag: 'span' });
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SPAN');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes root element via template ref', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Primitive, { ref: primitiveRef, as: 'button' }, { default: () => 'click' });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLButtonElement);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes slotted element via template ref in template mode', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
Primitive,
|
||||
{ ref: primitiveRef, as: 'template' },
|
||||
{ default: () => h('section', 'content') },
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLElement);
|
||||
expect((primitiveRef.value as HTMLElement).tagName).toBe('SECTION');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('updates template ref when element changes', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
const tag = ref<PrimitiveProps['as']>('div');
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
// @ts-expect-error — h() struggles with ref + broad PrimitiveProps['as'] union type
|
||||
h(Primitive, { ref: primitiveRef, as: tag.value }, { default: () => 'test' });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLDivElement);
|
||||
|
||||
tag.value = 'span';
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLSpanElement);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Nested as="template" ---
|
||||
|
||||
describe.each([1, 2, 3])('Primitive nested as="template" (depth=%i)', (depth) => {
|
||||
function wrapInTemplate(attrs: Array<Record<string, unknown>>, slot: () => ReturnType<typeof h>) {
|
||||
let current = slot;
|
||||
|
||||
for (let i = attrs.length - 1; i >= 0; i--) {
|
||||
const inner = current;
|
||||
current = () => h(Primitive, { as: 'template', ...attrs[i] }, { default: inner });
|
||||
}
|
||||
|
||||
return current();
|
||||
}
|
||||
|
||||
function makeAttrsPerLevel(base: string, depth: number) {
|
||||
return Array.from({ length: depth }, (_, i) => ({ [`data-level-${i}`]: `${base}-${i}` }));
|
||||
}
|
||||
|
||||
it('renders the inner child element', () => {
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('section', 'leaf'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.element.tagName).toBe('SECTION');
|
||||
expect(wrapper.text()).toBe('leaf');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('merges data attrs from all levels onto the leaf', () => {
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('div', 'child'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
expect(wrapper.attributes(`data-level-${i}`)).toBe(`v-${i}`);
|
||||
}
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('merges classes from all levels', () => {
|
||||
const attrs = Array.from({ length: depth }, (_, i) => ({ class: `level-${i}` }));
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('div', { class: 'leaf' }, 'child'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
expect(wrapper.classes()).toContain(`level-${i}`);
|
||||
}
|
||||
expect(wrapper.classes()).toContain('leaf');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('forwards event listeners from all levels', async () => {
|
||||
const handlers = Array.from({ length: depth }, () => vi.fn());
|
||||
const attrs = handlers.map(fn => ({ onClick: fn }));
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('button', 'click'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await wrapper.trigger('click');
|
||||
|
||||
for (const handler of handlers) {
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes inner element via template ref', async () => {
|
||||
const primitiveRef = shallowRef<Element | null>(null);
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
attrs[0] = { ...attrs[0], ref: primitiveRef };
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h('section', 'content'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(primitiveRef.value).toBeInstanceOf(HTMLElement);
|
||||
expect((primitiveRef.value as HTMLElement).tagName).toBe('SECTION');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders empty when innermost slot is missing', () => {
|
||||
const attrs = makeAttrsPerLevel('v', depth);
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => wrapInTemplate(attrs, () => h(Slot));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
2
vue/primitives/src/primitive/index.ts
Normal file
2
vue/primitives/src/primitive/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Primitive, type PrimitiveProps } from './Primitive';
|
||||
export { Slot } from './Slot';
|
||||
142
vue/primitives/src/utils/__test__/getRawChildren.bench.ts
Normal file
142
vue/primitives/src/utils/__test__/getRawChildren.bench.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h, render } from 'vue';
|
||||
import { PatchFlags } from '@vue/shared';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function keyedFragment(children: Array<ReturnType<typeof h>>) {
|
||||
return createVNode(Fragment, null, children, PatchFlags.KEYED_FRAGMENT);
|
||||
}
|
||||
|
||||
const flatChildren = [h('div'), h('span'), h('p')];
|
||||
|
||||
const keyedChildren = Array.from({ length: 10 }, (_, i) =>
|
||||
h('div', { key: i }, `child-${i}`),
|
||||
);
|
||||
|
||||
// ---- Processing cost ----
|
||||
|
||||
describe('getRawChildren', () => {
|
||||
bench('flat elements', () => {
|
||||
getRawChildren(flatChildren);
|
||||
});
|
||||
|
||||
bench('mixed elements and comments', () => {
|
||||
getRawChildren([
|
||||
createVNode(Comment, null, 'c'),
|
||||
h('div'),
|
||||
createVNode(Comment, null, 'c'),
|
||||
h('span'),
|
||||
createVNode(Comment, null, 'c'),
|
||||
]);
|
||||
});
|
||||
|
||||
bench('single fragment with children', () => {
|
||||
getRawChildren([createVNode(Fragment, null, [h('a'), h('b'), h('c')])]);
|
||||
});
|
||||
|
||||
bench('nested fragments (depth 5)', () => {
|
||||
let current: ReturnType<typeof h> = h('div');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
current = createVNode(Fragment, null, [current, h('span')]);
|
||||
}
|
||||
getRawChildren([current]);
|
||||
});
|
||||
|
||||
bench('wide fragment (50 children)', () => {
|
||||
const children = Array.from({ length: 50 }, (_, i) => h('div', `child-${i}`));
|
||||
getRawChildren([createVNode(Fragment, null, children)]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- BAIL path cost ----
|
||||
|
||||
describe('getRawChildren — BAIL path', () => {
|
||||
bench('1 keyed fragment (no BAIL)', () => {
|
||||
getRawChildren([keyedFragment([...keyedChildren])]);
|
||||
});
|
||||
|
||||
bench('2 keyed fragments (BAIL triggered)', () => {
|
||||
getRawChildren([
|
||||
keyedFragment(keyedChildren.slice(0, 5)),
|
||||
keyedFragment(keyedChildren.slice(5)),
|
||||
]);
|
||||
});
|
||||
|
||||
bench('3 keyed fragments (BAIL triggered)', () => {
|
||||
getRawChildren([
|
||||
keyedFragment(keyedChildren.slice(0, 3)),
|
||||
keyedFragment(keyedChildren.slice(3, 7)),
|
||||
keyedFragment(keyedChildren.slice(7)),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Render impact: optimized patchFlags vs BAIL ----
|
||||
|
||||
describe('patch — optimized vs BAIL patchFlag', () => {
|
||||
bench('patch with TEXT patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', null, 'a', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'b', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'c', PatchFlags.TEXT),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', null, 'x', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'y', PatchFlags.TEXT),
|
||||
createVNode('span', null, 'z', PatchFlags.TEXT),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
|
||||
bench('patch with BAIL patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', null, 'a', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'b', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'c', PatchFlags.BAIL),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', null, 'x', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'y', PatchFlags.BAIL),
|
||||
createVNode('span', null, 'z', PatchFlags.BAIL),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
|
||||
bench('patch with CLASS patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', { class: 'a' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'b' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'c' }, null, PatchFlags.CLASS),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', { class: 'x' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'y' }, null, PatchFlags.CLASS),
|
||||
createVNode('span', { class: 'z' }, null, PatchFlags.CLASS),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
|
||||
bench('patch with CLASS→BAIL patchFlag', () => {
|
||||
const container = document.createElement('div');
|
||||
const initial = h('div', null, [
|
||||
createVNode('span', { class: 'a' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'b' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'c' }, null, PatchFlags.BAIL),
|
||||
]);
|
||||
const updated = h('div', null, [
|
||||
createVNode('span', { class: 'x' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'y' }, null, PatchFlags.BAIL),
|
||||
createVNode('span', { class: 'z' }, null, PatchFlags.BAIL),
|
||||
]);
|
||||
render(initial, container);
|
||||
render(updated, container);
|
||||
});
|
||||
});
|
||||
63
vue/primitives/src/utils/__test__/getRawChildren.test.ts
Normal file
63
vue/primitives/src/utils/__test__/getRawChildren.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h } from 'vue';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
describe(getRawChildren, () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(getRawChildren([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns element vnodes as-is', () => {
|
||||
const div = h('div');
|
||||
const span = h('span');
|
||||
|
||||
const result = getRawChildren([div, span]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.type).toBe('div');
|
||||
expect(result[1]!.type).toBe('span');
|
||||
});
|
||||
|
||||
it('filters out Comment vnodes', () => {
|
||||
const div = h('div');
|
||||
const comment = createVNode(Comment, null, 'comment');
|
||||
|
||||
const result = getRawChildren([comment, div, comment]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.type).toBe('div');
|
||||
});
|
||||
|
||||
it('flattens Fragment children', () => {
|
||||
const fragment = createVNode(Fragment, null, [h('a'), h('b')]);
|
||||
|
||||
const result = getRawChildren([fragment]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.type).toBe('a');
|
||||
expect(result[1]!.type).toBe('b');
|
||||
});
|
||||
|
||||
it('recursively flattens nested Fragment children', () => {
|
||||
const innerFragment = createVNode(Fragment, null, [h('span')]);
|
||||
const outerFragment = createVNode(Fragment, null, [innerFragment, h('div')]);
|
||||
|
||||
const result = getRawChildren([outerFragment]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.type).toBe('span');
|
||||
expect(result[1]!.type).toBe('div');
|
||||
});
|
||||
|
||||
it('filters comments inside fragments', () => {
|
||||
const fragment = createVNode(Fragment, null, [
|
||||
createVNode(Comment, null, 'skip'),
|
||||
h('p'),
|
||||
]);
|
||||
|
||||
const result = getRawChildren([fragment]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.type).toBe('p');
|
||||
});
|
||||
});
|
||||
40
vue/primitives/src/utils/getRawChildren.ts
Normal file
40
vue/primitives/src/utils/getRawChildren.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { VNode } from 'vue';
|
||||
import { Comment, Fragment } from 'vue';
|
||||
import { PatchFlags } from '@vue/shared';
|
||||
|
||||
/**
|
||||
* Recursively extracts and flattens VNodes from potentially nested Fragments
|
||||
* while filtering out Comment nodes.
|
||||
*
|
||||
* @param children - Array of VNodes to process
|
||||
* @returns Flattened array of non-Comment VNodes
|
||||
*/
|
||||
export function getRawChildren(children: VNode[]): VNode[] {
|
||||
const result: VNode[] = [];
|
||||
flatten(children, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function flatten(children: VNode[], result: VNode[]): void {
|
||||
let keyedFragmentCount = 0;
|
||||
const startIdx = result.length;
|
||||
|
||||
for (const child of children) {
|
||||
if (child.type === Fragment) {
|
||||
if (child.patchFlag & PatchFlags.KEYED_FRAGMENT) {
|
||||
keyedFragmentCount++;
|
||||
}
|
||||
|
||||
flatten(child.children as VNode[], result);
|
||||
}
|
||||
else if (child.type !== Comment) {
|
||||
result.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyedFragmentCount > 1) {
|
||||
for (let i = startIdx; i < result.length; i++) {
|
||||
result[i]!.patchFlag = PatchFlags.BAIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user