diff --git a/playground/components/basic-demos/icon/demo.vue b/playground/components/basic-demos/icon/demo.vue index 2016246..1089fb7 100644 --- a/playground/components/basic-demos/icon/demo.vue +++ b/playground/components/basic-demos/icon/demo.vue @@ -1,20 +1,21 @@ diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 48020cb..665fdc7 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -7,5 +7,6 @@ export default defineNuxtConfig({ compatibilityDate: 'latest', antd: { icon: true, + component: true, }, }) diff --git a/src/module.ts b/src/module.ts index 0610c53..93ed572 100644 --- a/src/module.ts +++ b/src/module.ts @@ -7,6 +7,15 @@ import icons from './runtime/icons' // Module options TypeScript interface definition export interface ModuleOptions { + /** + * Enable components + * @default true + */ + component?: boolean + /** + * Enable icons + * @default false + */ icon?: boolean /** * Components to be included or excluded @@ -43,9 +52,15 @@ export default defineNuxtModule({ // Default configuration options of the Nuxt module defaults: { icon: false, + component: true, prefix: 'A', }, setup(_options, _nuxt) { + // Skip if both components and icons are disabled + if (_options.component === false && _options.icon !== true) { + return + } + const transpileList = _nuxt.options.build.transpile const appendTranspile = (dep: string) => { if (!transpileList.includes(dep)) { @@ -55,38 +70,45 @@ export default defineNuxtModule({ // Keep icon definition modules in Nuxt transform pipeline to avoid // cold-start interop inconsistency in dev SSR/hydration. - appendTranspile(libName) + if (_options.component !== false) { + appendTranspile(libName) + } + + // Always transpile icon libs because users may import icons directly appendTranspile(iconLibName) appendTranspile(iconsSvgLibName) - const componentMap = { - QRCode: 'Qrcode', - } - // Filter components based on include/exclude options - const filteredComponents = components.filter((comp) => { - if (_options.include?.length) { - return _options.include.includes(comp) + // Register components + if (_options.component !== false) { + const componentMap = { + QRCode: 'Qrcode', } - if (_options.exclude?.length) { - return !_options.exclude.includes(comp) - } - return true - }) + // Filter components based on include/exclude options + const filteredComponents = components.filter((comp) => { + if (_options.include?.length) { + return _options.include.includes(comp) + } + if (_options.exclude?.length) { + return !_options.exclude.includes(comp) + } + return true + }) - filteredComponents.forEach((comp) => { - let _comp: string = comp - if (comp in componentMap) { - _comp = componentMap[comp as keyof typeof componentMap] - } - addComponent({ - filePath: 'antdv-next', - export: comp, - name: _options.prefix + _comp, + filteredComponents.forEach((comp) => { + let _comp: string = comp + if (comp in componentMap) { + _comp = componentMap[comp as keyof typeof componentMap] + } + addComponent({ + filePath: 'antdv-next', + export: comp, + name: _options.prefix + _comp, + }) }) - }) + } - if (_options.icon !== false) { - appendTranspile(iconLibName) + // Register icons + if (_options.icon === true) { // Filter icons based on include/exclude options const filteredIcons = icons.filter((icon) => { if (_options.includeIcons?.length) { @@ -105,15 +127,19 @@ export default defineNuxtModule({ }) }) } - const resolver = createResolver(import.meta.url) - // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack` - addPlugin(resolver.resolve('./runtime/plugin')) - addServerPlugin(resolver.resolve('./runtime/server')) + // Only add plugins when components are enabled + if (_options.component !== false) { + const resolver = createResolver(import.meta.url) - // Check if the builder is Vite - if (_nuxt.options.builder === '@nuxt/vite-builder') { - addVitePlugin(dayjs()) + // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack` + addPlugin(resolver.resolve('./runtime/plugin')) + addServerPlugin(resolver.resolve('./runtime/server')) + + // Check if the builder is Vite + if (_nuxt.options.builder === '@nuxt/vite-builder') { + addVitePlugin(dayjs()) + } } }, }) diff --git a/test/hydration.scan.test.ts b/test/hydration.scan.test.ts index e69de29..14eb532 100644 --- a/test/hydration.scan.test.ts +++ b/test/hydration.scan.test.ts @@ -0,0 +1,522 @@ +import { existsSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { inspect } from 'node:util' +import { describe, expect, it } from 'vitest' +import { JSDOM } from 'jsdom' + +const localRequire = createRequire(import.meta.url) +const nuxtPkgPath = localRequire.resolve('nuxt/package.json') +const vueModulePath = localRequire.resolve('vue', { paths: [nuxtPkgPath] }) +const serverRendererModulePath = localRequire.resolve('@vue/server-renderer', { paths: [nuxtPkgPath] }) + +const testDom = new JSDOM('', { url: 'http://localhost' }) +const globalObject = globalThis as Record + +const setGlobal = (key: string, value: unknown) => { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }) +} + +setGlobal('window', testDom.window) +setGlobal('document', testDom.window.document) +setGlobal('navigator', testDom.window.navigator) +setGlobal('Node', testDom.window.Node) +setGlobal('Element', testDom.window.Element) +setGlobal('HTMLElement', testDom.window.HTMLElement) +setGlobal('SVGElement', testDom.window.SVGElement) +setGlobal('ShadowRoot', testDom.window.ShadowRoot) +setGlobal('MutationObserver', testDom.window.MutationObserver) +setGlobal('requestAnimationFrame', testDom.window.requestAnimationFrame + ? testDom.window.requestAnimationFrame.bind(testDom.window) + : (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16)) +setGlobal('cancelAnimationFrame', testDom.window.cancelAnimationFrame + ? testDom.window.cancelAnimationFrame.bind(testDom.window) + : (id: number) => clearTimeout(id)) +setGlobal('getComputedStyle', testDom.window.getComputedStyle.bind(testDom.window)) + +if (!testDom.window.matchMedia) { + testDom.window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false + }, + })) as typeof window.matchMedia +} +setGlobal('matchMedia', testDom.window.matchMedia.bind(testDom.window)) + +globalObject.ResizeObserver = class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +globalObject.IntersectionObserver = class IntersectionObserver { + disconnect() {} + observe() {} + unobserve() {} + takeRecords() { + return [] + } +} + +const { createSSRApp, defineComponent, h, nextTick } = localRequire(vueModulePath) as typeof import('vue') +const { renderToString } = localRequire(serverRendererModulePath) as typeof import('@vue/server-renderer') + +type RenderFn = () => ReturnType + +function createLoadErrorComponent(name: string, error: unknown) { + return defineComponent({ + name: `LoadError_${name}`, + setup() { + throw error + }, + }) +} + +function loadModule(path: string, name: string) { + try { + return localRequire(path) + } + catch (error) { + return { + __loadError: error, + default: createLoadErrorComponent(name, error), + } + } +} + +function pickExport(mod: Record, key = 'default') { + return (mod[key] || mod.default) as ReturnType +} + +const Affix = pickExport(loadModule('antdv-next/dist/affix/index', 'Affix')) +const Alert = pickExport(loadModule('antdv-next/dist/alert/index', 'Alert')) +const Anchor = pickExport(loadModule('antdv-next/dist/anchor/index', 'Anchor')) +const App = pickExport(loadModule('antdv-next/dist/app/index', 'App')) +const AutoComplete = pickExport(loadModule('antdv-next/dist/auto-complete/index', 'AutoComplete')) +const Avatar = pickExport(loadModule('antdv-next/dist/avatar/index', 'Avatar')) +const Badge = pickExport(loadModule('antdv-next/dist/badge/index', 'Badge')) +const Breadcrumb = pickExport(loadModule('antdv-next/dist/breadcrumb/index', 'Breadcrumb')) +const Button = pickExport(loadModule('antdv-next/dist/button/index', 'Button')) +const Calendar = pickExport(loadModule('antdv-next/dist/calendar/index', 'Calendar')) +const Card = pickExport(loadModule('antdv-next/dist/card/index', 'Card')) +const Carousel = pickExport(loadModule('antdv-next/dist/carousel/index', 'Carousel')) +const Cascader = pickExport(loadModule('antdv-next/dist/cascader/index', 'Cascader')) +const Checkbox = pickExport(loadModule('antdv-next/dist/checkbox/index', 'Checkbox')) +const Collapse = pickExport(loadModule('antdv-next/dist/collapse/index', 'Collapse')) +const ColorPicker = pickExport(loadModule('antdv-next/dist/color-picker/index', 'ColorPicker')) +const ConfigProvider = pickExport(loadModule('antdv-next/dist/config-provider/index', 'ConfigProvider')) +const DatePicker = pickExport(loadModule('antdv-next/dist/date-picker/index', 'DatePicker')) +const Descriptions = pickExport(loadModule('antdv-next/dist/descriptions/index', 'Descriptions')) +const Divider = pickExport(loadModule('antdv-next/dist/divider/index', 'Divider')) +const Drawer = pickExport(loadModule('antdv-next/dist/drawer/index', 'Drawer')) +const Dropdown = pickExport(loadModule('antdv-next/dist/dropdown/index', 'Dropdown')) +const Empty = pickExport(loadModule('antdv-next/dist/empty/index', 'Empty')) +const Flex = pickExport(loadModule('antdv-next/dist/flex/index', 'Flex')) +const FloatButton = pickExport(loadModule('antdv-next/dist/float-button/index', 'FloatButton')) +const Image = pickExport(loadModule('antdv-next/dist/image/index', 'Image')) +const Input = pickExport(loadModule('antdv-next/dist/input/index', 'Input')) +const InputNumber = pickExport(loadModule('antdv-next/dist/input-number/index', 'InputNumber')) +const Masonry = pickExport(loadModule('antdv-next/dist/masonry/index', 'Masonry')) +const Mentions = pickExport(loadModule('antdv-next/dist/mentions/index', 'Mentions')) +const Menu = pickExport(loadModule('antdv-next/dist/menu/index', 'Menu')) +const Modal = pickExport(loadModule('antdv-next/dist/modal/index', 'Modal')) +const Pagination = pickExport(loadModule('antdv-next/dist/pagination/index', 'Pagination')) +const Popconfirm = pickExport(loadModule('antdv-next/dist/popconfirm/index', 'Popconfirm')) +const Popover = pickExport(loadModule('antdv-next/dist/popover/index', 'Popover')) +const Progress = pickExport(loadModule('antdv-next/dist/progress/index', 'Progress')) +const QRCode = pickExport(loadModule('antdv-next/dist/qrcode/index', 'QRCode')) +const Radio = pickExport(loadModule('antdv-next/dist/radio/index', 'Radio')) +const Rate = pickExport(loadModule('antdv-next/dist/rate/index', 'Rate')) +const Result = pickExport(loadModule('antdv-next/dist/result/index', 'Result')) +const Segmented = pickExport(loadModule('antdv-next/dist/segmented/index', 'Segmented')) +const Select = pickExport(loadModule('antdv-next/dist/select/index', 'Select')) +const Skeleton = pickExport(loadModule('antdv-next/dist/skeleton/index', 'Skeleton')) +const Slider = pickExport(loadModule('antdv-next/dist/slider/index', 'Slider')) +const Space = pickExport(loadModule('antdv-next/dist/space/index', 'Space')) +const Spin = pickExport(loadModule('antdv-next/dist/spin/index', 'Spin')) +const Statistic = pickExport(loadModule('antdv-next/dist/statistic/index', 'Statistic')) +const Steps = pickExport(loadModule('antdv-next/dist/steps/index', 'Steps')) +const Switch = pickExport(loadModule('antdv-next/dist/switch/index', 'Switch')) +const Table = pickExport(loadModule('antdv-next/dist/table/index', 'Table')) +const Tabs = pickExport(loadModule('antdv-next/dist/tabs/index', 'Tabs')) +const Tag = pickExport(loadModule('antdv-next/dist/tag/index', 'Tag')) +const TimePicker = pickExport(loadModule('antdv-next/dist/time-picker/index', 'TimePicker')) +const Timeline = pickExport(loadModule('antdv-next/dist/timeline/index', 'Timeline')) +const Tooltip = pickExport(loadModule('antdv-next/dist/tooltip/index', 'Tooltip')) +const Tour = pickExport(loadModule('antdv-next/dist/tour/index', 'Tour')) +const Transfer = pickExport(loadModule('antdv-next/dist/transfer/index', 'Transfer')) +const Tree = pickExport(loadModule('antdv-next/dist/tree/index', 'Tree')) +const TreeSelect = pickExport(loadModule('antdv-next/dist/tree-select/index', 'TreeSelect')) +const Upload = pickExport(loadModule('antdv-next/dist/upload/index', 'Upload')) +const Watermark = pickExport(loadModule('antdv-next/dist/watermark/index', 'Watermark')) + +const formModule = loadModule('antdv-next/dist/form/index', 'Form') +const Form = pickExport(formModule) +const FormItem = pickExport(formModule, 'FormItem') + +const gridModule = loadModule('antdv-next/dist/grid/index', 'Grid') +const Row = pickExport(gridModule, 'Row') +const Col = pickExport(gridModule, 'Col') + +const layoutModule = loadModule('antdv-next/dist/layout/index', 'Layout') +const Layout = pickExport(layoutModule) +const LayoutHeader = pickExport(layoutModule, 'LayoutHeader') +const LayoutContent = pickExport(layoutModule, 'LayoutContent') + +const splitterModule = loadModule('antdv-next/dist/splitter/index', 'Splitter') +const Splitter = pickExport(splitterModule) +const SplitterPanel = pickExport(splitterModule, 'SplitterPanel') + +const typographyModule = loadModule('antdv-next/dist/typography/index', 'Typography') +const TypographyTitle = pickExport(typographyModule, 'TypographyTitle') + +const iconsModule = loadModule('@antdv-next/icons', 'Icons') +const HomeOutlined = pickExport(iconsModule, 'HomeOutlined') + +interface ComponentCase { + slug: string + sourceDemo: string + render: RenderFn +} + +interface ScanResult { + slug: string + sourceDemo: string + hydrationWarnings: string[] + runtimeErrors: string[] +} + +const SOURCE_COMPONENTS_DIR = new URL('../playground/pages/components', import.meta.url).pathname + +const sourceComponentSlugs = readdirSync(SOURCE_COMPONENTS_DIR, { withFileTypes: true }) + .filter(item => item.isDirectory()) + .map(item => item.name) + .filter(name => name !== 'overview') + .sort() + +function resolveDemoSource(slug: string): string { + const demoDir = join(SOURCE_COMPONENTS_DIR, slug, 'demo') + const preferred = join(demoDir, 'basic.vue') + if (existsSync(preferred)) { + return preferred + } + + const fallbacks = readdirSync(demoDir).filter(name => name.endsWith('.vue') && !name.startsWith('_')).sort() + if (fallbacks.length > 0) { + return join(demoDir, fallbacks[0]!) + } + + return preferred +} + +function renderMessage(): ReturnType { + return h(App, null, { + default: () => h(Button, { type: 'primary' }, { default: () => 'Message Demo Placeholder' }), + }) +} + +function renderNotification(): ReturnType { + return h(App, null, { + default: () => h(Button, { type: 'primary' }, { default: () => 'Notification Demo Placeholder' }), + }) +} + +const RENDERERS: Record = { + 'affix': () => h(Affix, { offsetTop: 0 }, { default: () => h('button', 'Affix') }), + 'alert': () => h(Alert, { message: 'Hydration Alert Demo', type: 'success' }), + 'anchor': () => h(Anchor, { items: [{ key: '1', href: '#part-1', title: 'Part 1' }] }), + 'app': () => h(App, null, { default: () => h('div', 'App') }), + 'auto-complete': () => h(AutoComplete, { value: 'one', options: [{ value: 'one' }, { value: 'two' }] }), + 'avatar': () => h(Avatar, { src: 'https://via.placeholder.com/40' }), + 'badge': () => h(Badge, { count: 5 }, { default: () => h('span', 'Badge') }), + 'breadcrumb': () => h(Breadcrumb, { items: [{ title: 'Home' }, { title: 'Page' }] }), + 'button': () => h(Button, { type: 'primary' }, { default: () => 'Button' }), + 'calendar': () => h(Calendar, { fullscreen: false }), + 'card': () => h(Card, { title: 'Card Title' }, { default: () => 'Card Body' }), + 'carousel': () => h(Carousel, null, { default: () => [h('div', 'Slide 1'), h('div', 'Slide 2')] }), + 'cascader': () => h(Cascader, { + value: ['zhejiang', 'hangzhou'], + options: [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [{ value: 'hangzhou', label: 'Hangzhou' }], + }, + ], + }), + 'checkbox': () => h(Checkbox, { checked: true }, { default: () => 'Checkbox' }), + 'collapse': () => h(Collapse, { items: [{ key: '1', label: 'Panel', children: 'Panel content' }] }), + 'color-picker': () => h(ColorPicker, { value: '#1677ff' }), + 'config-provider': () => h(ConfigProvider, null, { default: () => h(Button, { type: 'primary' }, { default: () => 'Config' }) }), + 'date-picker': () => h(DatePicker, { open: false }), + 'descriptions': () => h(Descriptions, { + title: 'User Info', + items: [{ key: 'name', label: 'Name', children: 'Jack' }], + }), + 'divider': () => h(Divider, null, { default: () => 'Divider' }), + 'drawer': () => h(Drawer, { open: false, title: 'Drawer' }), + 'dropdown': () => h(Dropdown, { + menu: { + items: [{ key: '1', label: 'First' }, { key: '2', label: 'Second' }], + }, + }, { + default: () => h(Button, { type: 'link' }, { default: () => 'Dropdown' }), + }), + 'empty': () => h(Empty), + 'flex': () => h(Flex, { gap: 'small' }, { + default: () => [ + h(Button, { type: 'default' }, { default: () => 'A' }), + h(Button, { type: 'default' }, { default: () => 'B' }), + ], + }), + 'float-button': () => h(FloatButton, { description: 'Help' }), + 'form': () => h(Form, { layout: 'vertical' }, { + default: () => h(FormItem, { label: 'Name', name: 'name' }, { + default: () => h(Input, { value: 'Jack' }), + }), + }), + 'grid': () => h(Row, { gutter: 8 }, { + default: () => [ + h(Col, { span: 12 }, { default: () => 'Left' }), + h(Col, { span: 12 }, { default: () => 'Right' }), + ], + }), + 'icon': () => h(HomeOutlined), + 'image': () => h(Image, { width: 120, src: 'https://via.placeholder.com/120x80' }), + 'input': () => h(Input, { value: 'Input' }), + 'input-number': () => h(InputNumber, { value: 3 }), + 'layout': () => h(Layout, { style: { height: '120px' } }, { + default: () => [ + h(LayoutHeader, null, { default: () => 'Header' }), + h(LayoutContent, null, { default: () => 'Content' }), + ], + }), + 'masonry': () => h(Masonry, { columns: 2 }, { + default: () => [h('div', 'A'), h('div', 'B'), h('div', 'C')], + }), + 'mentions': () => h(Mentions, { + value: '@antdv-next', + options: [{ value: 'antdv-next', label: 'antdv-next' }], + }), + 'menu': () => h(Menu, { + selectedKeys: ['1'], + items: [{ key: '1', label: 'Menu 1' }, { key: '2', label: 'Menu 2' }], + }), + 'message': renderMessage, + 'modal': () => h(Modal, { open: false, title: 'Modal title' }), + 'notification': renderNotification, + 'pagination': () => h(Pagination, { current: 1, pageSize: 10, total: 50 }), + 'popconfirm': () => h(Popconfirm, { title: 'Confirm?' }, { + default: () => h(Button, { type: 'link' }, { default: () => 'Delete' }), + }), + 'popover': () => h(Popover, { title: 'Title', content: 'Content' }, { + default: () => h(Button, { type: 'link' }, { default: () => 'Hover' }), + }), + 'progress': () => h(Progress, { percent: 35 }), + 'qr-code': () => h(QRCode, { value: 'https://www.antdv-next.com' }), + 'radio': () => h(Radio, { checked: true }, { default: () => 'Radio' }), + 'rate': () => h(Rate, { value: 3 }), + 'result': () => h(Result, { status: 'success', title: 'Success' }), + 'segmented': () => h(Segmented, { options: ['Daily', 'Weekly'], value: 'Daily' }), + 'select': () => h(Select, { value: 'a', open: false, options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] }), + 'skeleton': () => h(Skeleton, { active: true }), + 'slider': () => h(Slider, { value: 30 }), + 'space': () => h(Space, null, { + default: () => [ + h(Button, { type: 'default' }, { default: () => 'One' }), + h(Button, { type: 'default' }, { default: () => 'Two' }), + ], + }), + 'spin': () => h(Spin, { spinning: true }, { default: () => h('div', 'Loading content') }), + 'splitter': () => h(Splitter, { style: { height: '100px' } }, { + default: () => [ + h(SplitterPanel, { size: '50%' }, { default: () => 'Left Panel' }), + h(SplitterPanel, { size: '50%' }, { default: () => 'Right Panel' }), + ], + }), + 'statistic': () => h(Statistic, { title: 'Active Users', value: 1128 }), + 'steps': () => h(Steps, { current: 1, items: [{ title: 'Start' }, { title: 'Process' }, { title: 'Done' }] }), + 'switch': () => h(Switch, { checked: true }), + 'table': () => h(Table, { + pagination: false, + columns: [{ title: 'Name', dataIndex: 'name', key: 'name' }], + dataSource: [{ key: '1', name: 'Jack' }], + }), + 'tabs': () => h(Tabs, { + items: [ + { key: '1', label: 'Tab 1', children: 'Tab content 1' }, + { key: '2', label: 'Tab 2', children: 'Tab content 2' }, + ], + }), + 'tag': () => h(Tag, { color: 'blue' }, { default: () => 'Tag' }), + 'time-picker': () => h(TimePicker, { open: false }), + 'timeline': () => h(Timeline, { items: [{ children: 'Step 1' }, { children: 'Step 2' }] }), + 'tooltip': () => h(Tooltip, { title: 'Tooltip' }, { default: () => h('span', 'Hover me') }), + 'tour': () => h(Tour, { + open: false, + steps: [{ title: 'Step', description: 'Description', target: () => null }], + }), + 'transfer': () => h(Transfer, { + showSearch: false, + targetKeys: ['1'], + dataSource: [{ key: '1', title: 'Item 1' }, { key: '2', title: 'Item 2' }], + render: (item: { title: string }) => item.title, + }), + 'tree': () => h(Tree, { + defaultExpandAll: true, + treeData: [{ key: '0', title: 'Root', children: [{ key: '0-1', title: 'Child' }] }], + }), + 'tree-select': () => h(TreeSelect, { + value: '0-1', + treeData: [{ value: '0', title: 'Root', children: [{ value: '0-1', title: 'Node' }] }], + }), + 'typography': () => h(TypographyTitle, { level: 5 }, { default: () => 'Typography' }), + 'upload': () => h(Upload, { action: 'https://www.mocky.io/v2/5cc8019d300000980a055e76' }, { + default: () => h(Button, { type: 'default' }, { default: () => 'Upload' }), + }), + 'watermark': () => h(Watermark, { content: 'Antdv' }, { default: () => h('div', 'Watermark content') }), +} + +function pickHydrationMessages(messages: string[]) { + return messages.filter(msg => /hydrat|mismatch|server rendered/i.test(msg)) +} + +function safeLogText(value: unknown) { + if (typeof value === 'string') + return value + try { + return inspect(value, { depth: 2, maxArrayLength: 20, breakLength: 120 }) + } + catch { + return '[Unserializable]' + } +} + +function collectCases(): ComponentCase[] { + return sourceComponentSlugs + .map((slug) => { + const render = RENDERERS[slug] + if (!render) { + return null + } + return { + slug, + render, + sourceDemo: resolveDemoSource(slug), + } satisfies ComponentCase + }) + .filter((item): item is ComponentCase => item !== null) +} + +async function runHydrationScan(componentCase: ComponentCase): Promise { + const runtimeErrors: string[] = [] + const serverRoot = defineComponent({ + name: `HydrationCaseServer_${componentCase.slug}`, + setup() { + return () => componentCase.render() + }, + }) + + let serverHtml: string + try { + serverHtml = await renderToString(createSSRApp(serverRoot)) + } + catch (error) { + runtimeErrors.push(error instanceof Error ? error.stack || error.message : String(error)) + return { + slug: componentCase.slug, + sourceDemo: componentCase.sourceDemo, + hydrationWarnings: [], + runtimeErrors, + } + } + + testDom.window.document.body.innerHTML = `
${serverHtml}
` + + const warnLogs: string[] = [] + const originalWarn = console.warn + const originalError = console.error + console.warn = (...args: unknown[]) => { + warnLogs.push(args.map(safeLogText).join(' ')) + } + console.error = (...args: unknown[]) => { + runtimeErrors.push(args.map(safeLogText).join(' ')) + } + + try { + const clientRoot = defineComponent({ + name: `HydrationCaseClient_${componentCase.slug}`, + setup() { + return () => componentCase.render() + }, + }) + + const app = createSSRApp(clientRoot) + app.mount(testDom.window.document.querySelector('#app')!) + await nextTick() + await nextTick() + await new Promise(resolve => setTimeout(resolve, 0)) + app.unmount() + } + catch (error) { + runtimeErrors.push(error instanceof Error ? error.stack || error.message : String(error)) + } + finally { + console.warn = originalWarn + console.error = originalError + testDom.window.document.body.innerHTML = '' + } + + return { + slug: componentCase.slug, + sourceDemo: componentCase.sourceDemo, + hydrationWarnings: pickHydrationMessages([...warnLogs, ...runtimeErrors]), + runtimeErrors: runtimeErrors.filter(msg => !/hydrat|mismatch|server rendered/i.test(msg)), + } +} + +const describeHydrationScan = process.env.RUN_HYDRATION_SCAN === '1' ? describe : describe.skip + +describeHydrationScan('component hydration scan', () => { + it('checks one demo per component from playground list', async () => { + const cases = collectCases() + const results: ScanResult[] = [] + + for (const componentCase of cases) { + const result = await runHydrationScan(componentCase) + results.push(result) + } + + const hydrationProblemCases = results.filter(item => item.hydrationWarnings.length > 0) + const runtimeProblemCases = results.filter(item => item.runtimeErrors.length > 0) + + const missingCases = sourceComponentSlugs.filter(slug => !cases.some(item => item.slug === slug)) + if (missingCases.length > 0) { + throw new Error(`Missing hydration demo cases for: ${missingCases.join(', ')}`) + } + + if (runtimeProblemCases.length > 0 || hydrationProblemCases.length > 0) { + const issueText = [ + ...runtimeProblemCases.map((item) => { + const firstError = item.runtimeErrors[0] ? item.runtimeErrors[0].split('\n')[0] : 'Unknown runtime error' + return `[runtime] ${item.slug} (${item.sourceDemo}) -> ${firstError}` + }), + ...hydrationProblemCases.map(item => `[hydration] ${item.slug} (${item.sourceDemo}) -> ${item.hydrationWarnings.join(' | ')}`), + ].join('\n') + throw new Error(`Hydration scan found issues:\n${issueText}`) + } + + expect(results.length).toBe(sourceComponentSlugs.length) + }, 300_000) +})