@poveste/plugin-vue 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/dist/bundled/client/app/MountStory.js +3 -3
  3. package/dist/bundled/client/app/RenderStory.js +15 -9
  4. package/dist/bundled/client/app/Story.js +56 -43
  5. package/dist/bundled/client/app/Variant.js +32 -27
  6. package/dist/bundled/client/app/global-components.js +11 -10
  7. package/dist/bundled/client/app/host.js +2 -2
  8. package/dist/bundled/client/app/util.js +31 -26
  9. package/dist/bundled/client/client.js +4 -4
  10. package/dist/bundled/client/codegen.js +79 -60
  11. package/dist/bundled/client/server/Story.js +9 -7
  12. package/dist/bundled/client/server/Variant.js +1 -0
  13. package/dist/bundled/client/server/run.js +2 -2
  14. package/dist/client/app/MountStory.d.ts +3 -3
  15. package/dist/client/app/RenderStory.d.ts +1 -1
  16. package/dist/client/app/Story.d.ts +10 -7
  17. package/dist/client/app/Variant.d.ts +14 -13
  18. package/dist/client/app/render-context.d.ts +1 -1
  19. package/dist/client/app/util.d.ts +6 -3
  20. package/dist/client/codegen.d.ts +29 -0
  21. package/dist/client/server/Story.d.ts +17 -17
  22. package/dist/client/server/Variant.d.ts +10 -10
  23. package/dist/commands/generate-story.client.js +4 -4
  24. package/dist/commands/generate-story.server.js +2 -2
  25. package/dist/helpers.d.ts +2 -2
  26. package/dist/index.node.js +2 -0
  27. package/dist/util/list-components.d.ts +1 -1
  28. package/dist/util/list-components.js +63 -6
  29. package/dist/util/list-components.spec.d.ts +1 -0
  30. package/dist/util/list-components.spec.js +46 -0
  31. package/package.json +11 -10
  32. package/src/client/__tests__/codegen-attrs.spec.ts +149 -0
  33. package/src/client/app/MountStory.ts +5 -1
  34. package/src/client/app/RenderStory.ts +6 -1
  35. package/src/client/app/RouterLinkStub.ts +1 -1
  36. package/src/client/app/Story.ts +78 -37
  37. package/src/client/app/Variant.ts +26 -23
  38. package/src/client/app/__tests__/preview-variants.spec.ts +76 -0
  39. package/src/client/app/auto-props.ts +2 -2
  40. package/src/client/app/global-components.ts +20 -14
  41. package/src/client/app/host.ts +9 -6
  42. package/src/client/app/util.ts +17 -7
  43. package/src/client/codegen.ts +81 -29
  44. package/src/client/server/Story.ts +10 -4
  45. package/src/client/server/Variant.ts +3 -0
  46. package/src/client/server/run.ts +5 -4
  47. package/src/commands/generate-story.client.ts +4 -4
  48. package/src/commands/generate-story.server.ts +2 -2
  49. package/src/helpers.ts +2 -2
  50. package/src/index.node.ts +2 -0
  51. package/src/util/list-components.spec.ts +54 -0
  52. package/src/util/list-components.ts +68 -6
@@ -14,13 +14,13 @@ export default {
14
14
  label: 'File name',
15
15
  type: 'text',
16
16
  required: true,
17
- defaultValue: answers => answers.component?.replace(/[^/]+\/([^/]+)\.vue$/, '$1.story.vue'),
17
+ defaultValue: answers => answers['component']?.replace(/[^/]+\/([^/]+)\.vue$/, '$1.story.vue'),
18
18
  },
19
19
  ],
20
20
  clientAction: (params) => {
21
- const index = params.component.lastIndexOf('/');
22
- const dirname = params.component.substring(0, index + 1);
23
- const file = `${dirname}${params.fileName}`;
21
+ const index = params['component'].lastIndexOf('/');
22
+ const dirname = params['component'].substring(0, index + 1);
23
+ const file = `${dirname}${params['fileName']}`;
24
24
  const storyId = kebabCase(file.toLowerCase());
25
25
  openStory(storyId);
26
26
  },
@@ -7,11 +7,11 @@ export default {
7
7
  icon: 'https://vuejs.org/logo.svg',
8
8
  searchText: 'generate create',
9
9
  async serverAction(params) {
10
- const targetFile = path.join(path.dirname(params.component), params.fileName);
10
+ const targetFile = path.join(path.dirname(params['component']), params['fileName']);
11
11
  if (fs.existsSync(targetFile)) {
12
12
  throw new Error(`File ${targetFile} already exists`);
13
13
  }
14
- const { component, componentName, isTs } = await getComponentInfo(params.component);
14
+ const { component, componentName, isTs } = await getComponentInfo(params['component']);
15
15
  const content = `<script setup${isTs ? ' lang="ts"' : ''}>
16
16
  import ${componentName} from './${component}'
17
17
  </script>
package/dist/helpers.d.ts CHANGED
@@ -2,8 +2,8 @@ import type { Story, Variant } from '@poveste/shared';
2
2
  import type { App, Component } from 'vue';
3
3
  export interface Vue3StorySetupApi {
4
4
  app: App;
5
- story?: Story;
6
- variant?: Variant;
5
+ story?: Story | undefined;
6
+ variant?: Variant | undefined;
7
7
  addWrapper: (wrapper: Component) => void;
8
8
  }
9
9
  export type Vue3StorySetupHandler = (api: Vue3StorySetupApi) => Promise<void> | void;
@@ -27,6 +27,7 @@ export function HstVue() {
27
27
  if (this.meta.poveste?.isCollecting && id.endsWith('.vue')) {
28
28
  return `const _stubComponent = (name) => ['Story','Variant'].some(validName => name.toLowerCase() === validName.toLowerCase()) ? _resolveComponent(name) : ({ render: () => null });${code?.replaceAll('_resolveComponent(', '_stubComponent(') ?? ''}`;
29
29
  }
30
+ return undefined;
30
31
  },
31
32
  },
32
33
  ],
@@ -49,6 +50,7 @@ export function HstVue() {
49
50
  return listComponentFiles(api.payload.search, api.getConfig().storyMatch);
50
51
  }
51
52
  }
53
+ return undefined;
52
54
  },
53
55
  };
54
56
  }
@@ -1 +1 @@
1
- export declare function listComponentFiles(search?: string, ignore?: string[], limit?: number): Promise<string[]>;
1
+ export declare function listComponentFiles(search?: string, ignoredPatterns?: string[], limit?: number, cwd?: string): Promise<string[]>;
@@ -1,12 +1,69 @@
1
- import { globby } from 'globby';
2
- export async function listComponentFiles(search = '', ignore = [], limit = 10) {
3
- let files = await globby('**/*.vue', {
4
- gitignore: true,
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import ignore from 'ignore';
4
+ import { glob } from 'tinyglobby';
5
+ function toPosix(path) {
6
+ return path.split(sep).join('/');
7
+ }
8
+ /**
9
+ * Whether a file under `cwd` is git-ignored, the way globby's `gitignore: true`
10
+ * decided it: every `.gitignore` from the repository root down, each pattern
11
+ * relative to its own file. tinyglobby reads none of them (#839).
12
+ */
13
+ async function gitIgnoredFilter(cwd) {
14
+ let root = cwd;
15
+ while (!existsSync(join(root, '.git'))) {
16
+ const parent = dirname(root);
17
+ if (parent === root) {
18
+ root = cwd;
19
+ break;
20
+ }
21
+ root = parent;
22
+ }
23
+ const ancestors = [];
24
+ for (let dir = cwd; dir !== root;) {
25
+ dir = dirname(dir);
26
+ ancestors.unshift(join(dir, '.gitignore'));
27
+ }
28
+ // Shallow files first, so a nested `.gitignore` can override its parent.
29
+ const nested = (await glob('**/.gitignore', { cwd, absolute: true, dot: true, ignore: ['**/node_modules/**', '**/.git/**'] }))
30
+ .sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
31
+ const rules = ignore();
32
+ for (const file of [...ancestors, ...nested]) {
33
+ if (!existsSync(file)) {
34
+ continue;
35
+ }
36
+ const base = toPosix(relative(root, dirname(file)));
37
+ for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
38
+ const line = raw.trimEnd();
39
+ if (!line || line.startsWith('#')) {
40
+ continue;
41
+ }
42
+ const negated = line.startsWith('!');
43
+ let pattern = negated ? line.slice(1) : line;
44
+ if (base) {
45
+ // A pattern with a slash before its end is anchored to its own directory;
46
+ // one without matches at any depth below it.
47
+ const anchored = pattern.replace(/\/$/, '').includes('/');
48
+ pattern = anchored ? `${base}/${pattern.replace(/^\//, '')}` : `${base}/**/${pattern}`;
49
+ }
50
+ rules.add(negated ? `!${pattern}` : pattern);
51
+ }
52
+ }
53
+ return (file) => {
54
+ const path = toPosix(relative(root, resolve(cwd, file)));
55
+ return !path.startsWith('..') && rules.ignores(path);
56
+ };
57
+ }
58
+ export async function listComponentFiles(search = '', ignoredPatterns = [], limit = 10, cwd = process.cwd()) {
59
+ const isIgnored = await gitIgnoredFilter(cwd);
60
+ let files = (await glob('**/*.vue', {
61
+ cwd,
5
62
  ignore: [
6
63
  'node_modules',
7
- ...ignore,
64
+ ...ignoredPatterns,
8
65
  ],
9
- });
66
+ })).filter(file => !isIgnored(file));
10
67
  if (search) {
11
68
  const searchText = search.toLowerCase();
12
69
  files = files.filter(file => file.toLowerCase().includes(searchText));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { listComponentFiles } from './list-components.js';
6
+ const roots = [];
7
+ function tree(files) {
8
+ const root = mkdtempSync(join(tmpdir(), 'list-components-'));
9
+ roots.push(root);
10
+ for (const [path, content] of Object.entries(files)) {
11
+ mkdirSync(dirname(join(root, path)), { recursive: true });
12
+ writeFileSync(join(root, path), content);
13
+ }
14
+ return root;
15
+ }
16
+ afterEach(() => {
17
+ for (const root of roots.splice(0)) {
18
+ rmSync(root, { recursive: true, force: true });
19
+ }
20
+ });
21
+ // Listing honoured .gitignore through globby's `gitignore: true`, and tinyglobby
22
+ // has no such option, so the filtering is ours now (#839).
23
+ describe('listComponentFiles', () => {
24
+ it('leaves out components git ignores, from the root .gitignore and a nested one', async () => {
25
+ const root = tree({
26
+ '.git/HEAD': 'ref: refs/heads/main\n',
27
+ '.gitignore': 'generated/\n',
28
+ 'app/.gitignore': 'scratch/*\n!scratch/Kept.vue\n',
29
+ 'app/src/Button.vue': '',
30
+ 'app/src/generated/Output.vue': '',
31
+ 'app/scratch/Draft.vue': '',
32
+ 'app/scratch/Kept.vue': '',
33
+ });
34
+ const files = await listComponentFiles('', [], 10, join(root, 'app'));
35
+ expect(files.sort()).toEqual(['scratch/Kept.vue', 'src/Button.vue']);
36
+ });
37
+ it('still skips node_modules and the patterns it is given', async () => {
38
+ const root = tree({
39
+ '.git/HEAD': 'ref: refs/heads/main\n',
40
+ 'src/Button.vue': '',
41
+ 'src/Button.story.vue': '',
42
+ 'node_modules/pkg/Dep.vue': '',
43
+ });
44
+ expect(await listComponentFiles('', ['**/*.story.vue'], 10, root)).toEqual(['src/Button.vue']);
45
+ });
46
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@poveste/plugin-vue",
3
3
  "type": "module",
4
- "version": "0.14.0",
4
+ "version": "0.15.0",
5
5
  "description": "Poveste plugin for Vue.js support",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan",
@@ -70,23 +70,24 @@
70
70
  "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
71
71
  },
72
72
  "peerDependencies": {
73
- "poveste": "^0.14.0",
73
+ "poveste": "^0.15.0",
74
74
  "vue": "^3.5.26"
75
75
  },
76
76
  "dependencies": {
77
- "@poveste/controls": "^0.14.0",
78
- "@poveste/shared": "^0.14.0",
79
- "@poveste/vendors": "^0.14.0",
77
+ "@poveste/controls": "^0.15.0",
78
+ "@poveste/shared": "^0.15.0",
79
+ "@poveste/vendors": "^0.15.0",
80
80
  "change-case": "^5.4.4",
81
- "globby": "^14.0.2",
81
+ "ignore": "^7.0.6",
82
82
  "launch-editor": "^2.9.1",
83
- "pathe": "^1.1.2"
83
+ "pathe": "^2.0.3",
84
+ "tinyglobby": "^0.2.17"
84
85
  },
85
86
  "devDependencies": {
86
87
  "@types/node": "^22.10.1",
87
- "concurrently": "^9.1.0",
88
- "poveste": "0.14.0",
89
- "typescript": "5.6.3",
88
+ "concurrently": "^10.0.5",
89
+ "poveste": "0.15.0",
90
+ "typescript": "6.0.3",
90
91
  "vite": "^8.2.0",
91
92
  "vitest": "^5.0.0",
92
93
  "vue": "^3.5.26"
@@ -0,0 +1,149 @@
1
+ import type { Variant } from '@poveste/shared'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { h } from 'vue'
4
+ import { delimitAttr, generateSourceCode } from '../codegen.js'
5
+
6
+ /*
7
+ * The source pane shows the template that produced the variant, so a handler
8
+ * has to appear as its author wrote it. The pass this replaces escaped `'` for
9
+ * a JavaScript string and rewrote `"` into `'` — neither of which is the
10
+ * attribute the result lands in — and each half reached the reader alone (#602).
11
+ *
12
+ * A case carrying both quote characters is the one that matters: each half
13
+ * fails on its own, so a fixture with only one of them passes a half-fix.
14
+ */
15
+ describe('delimitAttr', () => {
16
+ it('leaves code with no quotes in it alone, under the usual delimiter', () => {
17
+ expect(delimitAttr(['onClick'])).toEqual({ quote: '"', lines: ['onClick'] })
18
+ })
19
+
20
+ // Was shown as `() => alert('hi')` — the reader's double quotes rewritten.
21
+ it('takes the other delimiter rather than rewriting a double quote', () => {
22
+ expect(delimitAttr(['() => alert("hi")'])).toEqual({
23
+ quote: '\'',
24
+ lines: ['() => alert("hi")'],
25
+ })
26
+ })
27
+
28
+ // Was shown as `() => alert(\'hi\')`, backslashes and all.
29
+ it('leaves a single quote unescaped, since the attribute is not a JS string', () => {
30
+ expect(delimitAttr(['() => alert(\'hi\')'])).toEqual({
31
+ quote: '"',
32
+ lines: ['() => alert(\'hi\')'],
33
+ })
34
+ })
35
+
36
+ // Both characters, so whichever delimiter is chosen has to be escaped inside
37
+ // it. Two doubles against one single here, so the single is the cheaper one.
38
+ it('escapes the quote that appears less often when the code carries both', () => {
39
+ expect(delimitAttr(['() => console.log("it\'s here")'])).toEqual({
40
+ quote: '\'',
41
+ lines: ['() => console.log("it&#39;s here")'],
42
+ })
43
+ })
44
+
45
+ // Four singles against two doubles, so the double is the cheaper one here.
46
+ it('escapes the double quote instead when that is the rarer one', () => {
47
+ expect(delimitAttr(['() => alert(\'a\', \'b\', "c")'])).toEqual({
48
+ quote: '"',
49
+ lines: ['() => alert(\'a\', \'b\', &quot;c&quot;)'],
50
+ })
51
+ })
52
+
53
+ // The alert CodeQL raised: the escaping pass never escaped a backslash, so
54
+ // `() => alert('it\'s')` went in carrying one and came out carrying four.
55
+ it('passes a backslash through untouched', () => {
56
+ const written = '() => alert(\'it\\\'s\')'
57
+
58
+ expect(delimitAttr([written]).lines[0]).toBe(written)
59
+ })
60
+
61
+ it('reads the whole value when deciding, not one line of it', () => {
62
+ expect(delimitAttr(['() => {', ' alert("hi")', '}']).quote).toBe('\'')
63
+ })
64
+
65
+ it('escapes on every line, not only the one that decided it', () => {
66
+ expect(delimitAttr(['() => {', ' alert("a")', ' alert(\'b\')', ' alert(\'c\')', '}']).lines).toEqual([
67
+ '() => {',
68
+ ' alert(&quot;a&quot;)',
69
+ ' alert(\'b\')',
70
+ ' alert(\'c\')',
71
+ '}',
72
+ ])
73
+ })
74
+ })
75
+
76
+ /*
77
+ * `delimitAttr` could be right and unused, so these drive the generator itself.
78
+ *
79
+ * The handler states its own source rather than being written as one. A `.vue`
80
+ * ships to the browser untransformed, but this spec does not: esbuild rewrote
81
+ * `alert('it\'s')` in the fixture to `alert("it's")` before `toString()` ever
82
+ * saw it, so a handler written here is not the text the generator receives.
83
+ * Overriding `toString` is what makes the input exact.
84
+ */
85
+ const WITH_DOUBLE = '() => alert("hi")'
86
+ const WITH_SINGLE = '() => alert(\'hi\')'
87
+ const WITH_BOTH = '() => console.log("it\'s here")'
88
+ const WITH_BACKSLASH = String.raw`() => alert('it\'s')`
89
+
90
+ function handlerWritten(source: string): () => void {
91
+ const handler = (): void => {}
92
+ handler.toString = () => source
93
+ return handler
94
+ }
95
+
96
+ function variantWith(source: string): Variant {
97
+ return {
98
+ state: {},
99
+ slots: () => ({ default: () => h('button', { onClick: handlerWritten(source) }, 'go') }),
100
+ } as unknown as Variant
101
+ }
102
+
103
+ describe('generateSourceCode, for an event handler', () => {
104
+ it('shows a double quote the author wrote, under the other delimiter', async () => {
105
+ expect(await generateSourceCode(variantWith(WITH_DOUBLE))).toContain(`@click='${WITH_DOUBLE}'`)
106
+ })
107
+
108
+ it('shows a single quote unescaped, under the usual delimiter', async () => {
109
+ expect(await generateSourceCode(variantWith(WITH_SINGLE))).toContain(`@click="${WITH_SINGLE}"`)
110
+ })
111
+
112
+ it('escapes only the rarer quote when the handler carries both', async () => {
113
+ expect(await generateSourceCode(variantWith(WITH_BOTH)))
114
+ .toContain('@click=\'() => console.log("it&#39;s here")\'')
115
+ })
116
+
117
+ it('shows a backslash the author wrote exactly as written', async () => {
118
+ expect(await generateSourceCode(variantWith(WITH_BACKSLASH))).toContain(WITH_BACKSLASH)
119
+ })
120
+
121
+ // The alert itself: one backslash in, four out.
122
+ it('does not multiply the backslash it was given', async () => {
123
+ const source = await generateSourceCode(variantWith(WITH_BACKSLASH))
124
+
125
+ expect(source.match(/\\/g)).toHaveLength(1)
126
+ })
127
+ })
128
+
129
+ /*
130
+ * A string prop is the other way a `"` reaches an attribute, and it took the
131
+ * delimiter fix later than the handlers did — the element below rendered its
132
+ * directives correctly and mangled this one.
133
+ */
134
+ describe('generateSourceCode, for a string prop', () => {
135
+ function variantWithTitle(title: string): Variant {
136
+ return {
137
+ state: {},
138
+ slots: () => ({ default: () => h('div', { title }, 'go') }),
139
+ } as unknown as Variant
140
+ }
141
+
142
+ it('leaves a plain value under the usual delimiter', async () => {
143
+ expect(await generateSourceCode(variantWithTitle('a calm title'))).toContain('title="a calm title"')
144
+ })
145
+
146
+ it('takes the other delimiter rather than closing the attribute early', async () => {
147
+ expect(await generateSourceCode(variantWithTitle('He said "hi"'))).toContain(`title='He said "hi"'`)
148
+ })
149
+ })
@@ -40,9 +40,13 @@ export default _defineComponent({
40
40
  value: 0,
41
41
  },
42
42
  })
43
- let host: ReturnType<typeof createPreviewHost>
43
+ let host: ReturnType<typeof createPreviewHost> | null = null
44
44
 
45
45
  async function mountStory() {
46
+ if (!el.value) {
47
+ return
48
+ }
49
+
46
50
  host = createPreviewHost({
47
51
  name: 'MountStorySubApp',
48
52
  el: el.value,
@@ -40,7 +40,7 @@ export default _defineComponent({
40
40
 
41
41
  setup(props, { emit }) {
42
42
  const sandbox = _ref<HTMLDivElement>()
43
- let host: ReturnType<typeof createPreviewHost>
43
+ let host: ReturnType<typeof createPreviewHost> | null = null
44
44
  let mounting = false
45
45
 
46
46
  const externalState = reactive<Variant['state']>({})
@@ -73,6 +73,11 @@ export default _defineComponent({
73
73
  renderContext.currentVariant = props.variant
74
74
  renderContext.slotName = props.slotName
75
75
 
76
+ if (!sandbox.value) {
77
+ mounting = false
78
+ return
79
+ }
80
+
76
81
  host = createPreviewHost({
77
82
  name: 'RenderStorySubApp',
78
83
  el: sandbox.value,
@@ -36,7 +36,7 @@ export const RouterLinkStub = defineComponent({
36
36
  render() {
37
37
  const route = computed(() => defaultRoute)
38
38
  // mock reasonable return values to mimic vue-router's useLink
39
- const children = this.$slots?.default?.({
39
+ const children = this.$slots?.['default']?.({
40
40
  route,
41
41
  href: computed(() => route.value.href),
42
42
  isActive: computed(() => false),
@@ -1,8 +1,10 @@
1
1
  import type { Story } from '@poveste/shared'
2
2
  import type { PropType, VNode } from 'vue'
3
+ import type { PreviewRenderContext } from './render-context.js'
3
4
  import { omitInheritStoryProps } from '@poveste/shared'
4
- import { cloneVNode, computed, defineComponent, getCurrentInstance, h, isRef, provide, reactive, useAttrs } from 'vue'
5
+ import { cloneVNode, computed, defineComponent, h, isRef, provide, reactive, useAttrs } from 'vue'
5
6
  import { useRenderContext } from './render-context.js'
7
+ import { useInstance } from './util.js'
6
8
  import Variant from './Variant.js'
7
9
 
8
10
  export default defineComponent({
@@ -25,7 +27,7 @@ export default defineComponent({
25
27
  },
26
28
 
27
29
  setup(props) {
28
- const vm = getCurrentInstance()
30
+ const vm = useInstance('Story')
29
31
 
30
32
  const attrs = useAttrs() as {
31
33
  story: Story
@@ -100,7 +102,7 @@ export default defineComponent({
100
102
  }
101
103
 
102
104
  Object.assign(attrs.story, {
103
- slots: () => vm.proxy.$slots,
105
+ slots: () => vm.slots,
104
106
  })
105
107
  }
106
108
 
@@ -112,6 +114,32 @@ export default defineComponent({
112
114
  updateStory,
113
115
  }
114
116
 
117
+ /**
118
+ * What a `<Variant>` takes from its story: the attrs the story was given, then
119
+ * what the story carries, neither overriding what the variant set itself.
120
+ *
121
+ * Both render passes need it. `autoPropsDisabled` set on a story reached only
122
+ * the mount pass, so a story turning auto-props off still offered controls in
123
+ * the realm that renders it (#890).
124
+ */
125
+ function inheritedFromStory(vnode: VNode) {
126
+ const inherited: Record<string, any> = {}
127
+
128
+ for (const attr in vm.attrs) {
129
+ if (typeof vnode.props?.[attr] === 'undefined') {
130
+ inherited[attr] = vm.attrs[attr]
131
+ }
132
+ }
133
+
134
+ for (const attr in attrs.story) {
135
+ if (!omitInheritStoryProps.includes(attr) && typeof vnode.props?.[attr] === 'undefined') {
136
+ inherited[attr] = Reflect.get(attrs.story, attr)
137
+ }
138
+ }
139
+
140
+ return inherited
141
+ }
142
+
115
143
  function renderMountStory() {
116
144
  let index = 0
117
145
 
@@ -126,8 +154,9 @@ export default defineComponent({
126
154
  // that as it registers, so the gate held for the first render only and
127
155
  // turned the re-render a retarget needs into a full mount of every
128
156
  // sibling.
129
- const targetIndex = renderContext?.targetVariantId
130
- ? attrs.story.variants.findIndex(v => v.id === renderContext.targetVariantId)
157
+ const targetVariantId = renderContext?.targetVariantId
158
+ const targetIndex = targetVariantId
159
+ ? attrs.story.variants.findIndex(v => v.id === targetVariantId)
131
160
  : -1
132
161
 
133
162
  const applyAttrs = (vnodes: VNode[]) => {
@@ -159,21 +188,11 @@ export default defineComponent({
159
188
  nextProps.key = variant.id
160
189
  }
161
190
 
162
- if (!vnode.props?.initState && !vnode.props?.['init-state']) {
191
+ if (!vnode.props?.['initState'] && !vnode.props?.['init-state']) {
163
192
  nextProps.initState = props.initState
164
193
  }
165
194
 
166
- for (const attr in vm.proxy.$attrs) {
167
- if (typeof vnode.props?.[attr] === 'undefined') {
168
- nextProps[attr] = vm.proxy.$attrs[attr]
169
- }
170
- }
171
-
172
- for (const attr in attrs.story) {
173
- if (!omitInheritStoryProps.includes(attr) && typeof vnode.props?.[attr] === 'undefined') {
174
- nextProps[attr] = attrs.story[attr]
175
- }
176
- }
195
+ Object.assign(nextProps, inheritedFromStory(vnode))
177
196
 
178
197
  result.push(cloneVNode(vnode, nextProps))
179
198
  continue
@@ -189,38 +208,59 @@ export default defineComponent({
189
208
  return result
190
209
  }
191
210
 
192
- return applyAttrs(vm.proxy.$slots.default?.() ?? [])
211
+ return applyAttrs(vm.slots['default']?.() ?? [])
193
212
  }
194
213
 
195
- function renderPreviewStory() {
196
- renderContext.nextVariantIndex.value = 0
214
+ function countVariantVnodes(vnodes: VNode[]): number {
215
+ let count = 0
216
+ for (const vnode of vnodes) {
217
+ // @ts-expect-error custom option
218
+ if (vnode?.type?.__povesteType === 'variant') {
219
+ count++
220
+ }
221
+ else if (Array.isArray(vnode?.children)) {
222
+ count += countVariantVnodes(vnode.children as VNode[])
223
+ }
224
+ }
225
+ return count
226
+ }
227
+
228
+ function renderPreviewStory(context: PreviewRenderContext) {
229
+ context.nextVariantIndex.value = 0
197
230
 
198
231
  const slotProps = {
199
- state: renderContext.externalState,
232
+ state: context.externalState,
200
233
  }
201
234
 
202
- const children = []
235
+ const children: VNode[] = []
203
236
 
204
- if (renderContext.slotName === 'controls') {
205
- children.push(...(vm.proxy.$slots.controls?.(slotProps) ?? []))
237
+ if (context.slotName === 'controls') {
238
+ children.push(...(vm.slots['controls']?.(slotProps) ?? []))
206
239
  }
207
240
 
208
- if (renderContext.slotName === 'default' || attrs.story.meta?.hasVariantChildComponents) {
209
- children.push(...(vm.proxy.$slots.default?.(slotProps) ?? []))
241
+ if (context.slotName === 'default' || attrs.story.meta?.hasVariantChildComponents) {
242
+ children.push(...(vm.slots['default']?.(slotProps) ?? []))
210
243
  }
211
244
 
212
245
  // Same story as the mount pass above: every Variant component here runs
213
246
  // its full setup and then all but the current one render null (#197).
214
247
  // Keep only the current one, and seed the positional index so it still
215
248
  // resolves to the right variant data. Filtering only the default slot —
216
- // the controls slot carries controls, and stories with variants in child
217
- // components are invisible to a vnode walk.
249
+ // the controls slot carries controls.
250
+ //
251
+ // Gated on the walk seeing every variant, not on `hasVariantChildComponents`,
252
+ // for the reason the mount pass gives: every explicit <Variant> sets that flag
253
+ // as it renders, so from the second render on the filter never ran, and each
254
+ // retarget rendered all N Variant components to show one (#873). A story
255
+ // whose variants live in child components still renders them all, because
256
+ // its walk finds fewer than `variants.length`.
257
+ const currentVariant = context.currentVariant
218
258
  if (
219
- renderContext.slotName === 'default'
220
- && renderContext.currentVariant
221
- && !attrs.story.meta?.hasVariantChildComponents
259
+ context.slotName === 'default'
260
+ && currentVariant
261
+ && countVariantVnodes(children) === attrs.story.variants.length
222
262
  ) {
223
- const targetIndex = attrs.story.variants.findIndex(v => v.id === renderContext.currentVariant.id)
263
+ const targetIndex = attrs.story.variants.findIndex(v => v.id === currentVariant.id)
224
264
  if (targetIndex !== -1) {
225
265
  let seen = 0
226
266
  let kept = false
@@ -230,7 +270,7 @@ export default defineComponent({
230
270
  // @ts-expect-error custom option
231
271
  if (vnode?.type?.__povesteType === 'variant') {
232
272
  if (seen === targetIndex) {
233
- result.push(vnode)
273
+ result.push(cloneVNode(vnode, inheritedFromStory(vnode)))
234
274
  kept = true
235
275
  }
236
276
  seen++
@@ -245,7 +285,7 @@ export default defineComponent({
245
285
  }
246
286
  const filtered = keepTargetVariant(children)
247
287
  if (kept) {
248
- renderContext.nextVariantIndex.value = targetIndex
288
+ context.nextVariantIndex.value = targetIndex
249
289
  return filtered
250
290
  }
251
291
  }
@@ -263,14 +303,15 @@ export default defineComponent({
263
303
  if (firstVariant?.id === '_default') {
264
304
  return h(Variant, {
265
305
  variant: firstVariant,
266
- initState: this.initState,
306
+ ...this.initState ? { initState: this.initState } : {},
267
307
  implicit: true,
268
308
  ...this.$attrs,
269
309
  }, this.$slots)
270
310
  }
271
311
 
272
- if (this.renderContext?.mode === 'render') {
273
- return this.renderPreviewStory()
312
+ const renderContext = this.renderContext
313
+ if (renderContext?.mode === 'render') {
314
+ return this.renderPreviewStory(renderContext)
274
315
  }
275
316
 
276
317
  return this.renderMountStory()