@poveste/plugin-vue 0.14.0 → 0.16.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.
- package/README.md +2 -2
- package/components.d.ts +4 -0
- package/dist/bundled/client/app/MountStory.js +8 -3
- package/dist/bundled/client/app/RenderStory.js +15 -9
- package/dist/bundled/client/app/Story.js +76 -62
- package/dist/bundled/client/app/Variant.js +48 -42
- package/dist/bundled/client/app/context.js +5 -0
- package/dist/bundled/client/app/global-components.js +11 -10
- package/dist/bundled/client/app/host.js +2 -2
- package/dist/bundled/client/app/render-context.js +7 -7
- package/dist/bundled/client/app/util.js +36 -26
- package/dist/bundled/client/client.js +4 -4
- package/dist/bundled/client/codegen.js +79 -60
- package/dist/bundled/client/context.js +20 -0
- package/dist/bundled/client/server/Story.js +28 -25
- package/dist/bundled/client/server/Variant.js +13 -16
- package/dist/bundled/client/server/context.js +5 -0
- package/dist/bundled/client/server/run.js +29 -29
- package/dist/client/app/MountStory.d.ts +12 -3
- package/dist/client/app/RenderStory.d.ts +1 -1
- package/dist/client/app/Story.d.ts +10 -7
- package/dist/client/app/Variant.d.ts +14 -13
- package/dist/client/app/context.d.ts +9 -0
- package/dist/client/app/render-context.d.ts +16 -1
- package/dist/client/app/util.d.ts +38 -3
- package/dist/client/codegen.d.ts +29 -0
- package/dist/client/context.d.ts +40 -0
- package/dist/client/server/Story.d.ts +17 -17
- package/dist/client/server/Variant.d.ts +10 -10
- package/dist/client/server/context.d.ts +7 -0
- package/dist/commands/generate-story.client.js +4 -4
- package/dist/commands/generate-story.server.js +2 -2
- package/dist/helpers.d.ts +2 -2
- package/dist/index.node.js +2 -0
- package/dist/util/list-components.d.ts +1 -1
- package/dist/util/list-components.js +63 -6
- package/dist/util/list-components.spec.d.ts +1 -0
- package/dist/util/list-components.spec.js +46 -0
- package/package.json +17 -16
- package/src/client/__tests__/codegen-attrs.spec.ts +149 -0
- package/src/client/__tests__/context.spec.ts +50 -0
- package/src/client/app/MountStory.ts +11 -1
- package/src/client/app/RenderStory.ts +6 -1
- package/src/client/app/RouterLinkStub.ts +1 -1
- package/src/client/app/Story.ts +85 -39
- package/src/client/app/Variant.ts +35 -27
- package/src/client/app/__tests__/preview-variants.spec.ts +76 -0
- package/src/client/app/__tests__/state-sync.spec.ts +113 -2
- package/src/client/app/__tests__/writable-binding.spec.ts +39 -0
- package/src/client/app/auto-props.ts +2 -2
- package/src/client/app/context.ts +17 -0
- package/src/client/app/global-components.ts +20 -14
- package/src/client/app/host.ts +9 -6
- package/src/client/app/render-context.ts +25 -5
- package/src/client/app/util.ts +104 -9
- package/src/client/codegen.ts +81 -29
- package/src/client/context.ts +66 -0
- package/src/client/server/Story.ts +16 -10
- package/src/client/server/Variant.ts +13 -14
- package/src/client/server/__tests__/nesting-errors.spec.ts +48 -0
- package/src/client/server/context.ts +18 -0
- package/src/client/server/run.ts +11 -7
- package/src/commands/generate-story.client.ts +4 -4
- package/src/commands/generate-story.server.ts +2 -2
- package/src/helpers.ts +2 -2
- package/src/index.node.ts +2 -0
- package/src/util/list-components.spec.ts +54 -0
- package/src/util/list-components.ts +68 -6
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Story as StoryData, Variant as VariantData } from '@poveste/shared'
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { createSSRApp, defineComponent, h, reactive } from 'vue'
|
|
5
|
+
import { renderToString } from 'vue/server-renderer'
|
|
6
|
+
import { provideRenderContext } from '../render-context.js'
|
|
7
|
+
import Story from '../Story.js'
|
|
8
|
+
import Variant from '../Variant.js'
|
|
9
|
+
|
|
10
|
+
const COUNT = 50
|
|
11
|
+
|
|
12
|
+
function storyData(): StoryData {
|
|
13
|
+
const variants = Array.from({ length: COUNT }, (_, i) => ({ id: `v${i + 1}`, title: `Variant ${i + 1}`, state: {} })) as VariantData[]
|
|
14
|
+
// `hasVariantChildComponents` is what every explicit <Variant> sets as it renders,
|
|
15
|
+
// so this is the story as a retarget finds it: past its first render.
|
|
16
|
+
return reactive({ id: 'grid', title: 'Grid', variants, meta: { hasVariantChildComponents: true } }) as unknown as StoryData
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Renders `storyComponent` for `target` in a render context, and counts the Variant components created. */
|
|
20
|
+
async function renderPreview(storyComponent: Component, story: StoryData, target: string) {
|
|
21
|
+
let variantsCreated = 0
|
|
22
|
+
const currentVariant = story.variants.find(variant => variant.id === target) ?? null
|
|
23
|
+
const app = createSSRApp(defineComponent({
|
|
24
|
+
setup() {
|
|
25
|
+
provideRenderContext(reactive({ mode: 'render', slotName: 'default', currentVariant, externalState: {}, nextVariantIndex: { value: 0 } }))
|
|
26
|
+
return () => h(storyComponent, { story })
|
|
27
|
+
},
|
|
28
|
+
}))
|
|
29
|
+
app.mixin({
|
|
30
|
+
beforeCreate() {
|
|
31
|
+
if (this.$options.name === 'Variant') {
|
|
32
|
+
variantsCreated++
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
const html = await renderToString(app)
|
|
37
|
+
return { html, variantsCreated }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const button = (n: number) => h('button', `Button ${n}`)
|
|
41
|
+
|
|
42
|
+
describe('a preview render of one variant', () => {
|
|
43
|
+
// #873: gated on `hasVariantChildComponents`, the filter below never ran after a
|
|
44
|
+
// story's first render, and every retarget rendered all N Variant components.
|
|
45
|
+
it('creates only the target Variant when every variant is a direct child', async () => {
|
|
46
|
+
const DirectStory = defineComponent({
|
|
47
|
+
props: { story: { type: Object, required: true } },
|
|
48
|
+
setup: props => () => h(Story, { story: props.story }, {
|
|
49
|
+
default: () => Array.from({ length: COUNT }, (_, i) => h(Variant, { key: i }, { default: () => button(i + 1) })),
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const { html, variantsCreated } = await renderPreview(DirectStory, storyData(), 'v37')
|
|
54
|
+
|
|
55
|
+
expect(variantsCreated).toBe(1)
|
|
56
|
+
expect(html).toContain('Button 37')
|
|
57
|
+
expect(html).not.toContain('Button 36<')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// A walk of the slot's vnodes cannot see a <Variant> inside a child component,
|
|
61
|
+
// so positions would not line up with `story.variants`, and every one renders.
|
|
62
|
+
it('still renders every Variant when they live in a child component', async () => {
|
|
63
|
+
const Rows = defineComponent({
|
|
64
|
+
setup: () => () => Array.from({ length: COUNT }, (_, i) => h(Variant, { key: i }, { default: () => button(i + 1) })),
|
|
65
|
+
})
|
|
66
|
+
const WrappedStory = defineComponent({
|
|
67
|
+
props: { story: { type: Object, required: true } },
|
|
68
|
+
setup: props => () => h(Story, { story: props.story }, { default: () => h(Rows) }),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const { html, variantsCreated } = await renderPreview(WrappedStory, storyData(), 'v37')
|
|
72
|
+
|
|
73
|
+
expect(variantsCreated).toBe(COUNT)
|
|
74
|
+
expect(html).toContain('Button 37')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { autoPropsStateKeys } from '@poveste/shared'
|
|
2
|
-
import { nextTick as bundledNextTick, reactive as bundledReactive } from '@poveste/vendors/vue'
|
|
2
|
+
import { nextTick as bundledNextTick, reactive as bundledReactive, ref as bundledRef } from '@poveste/vendors/vue'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import { nextTick, reactive, watch } from 'vue'
|
|
4
|
+
import { markRaw, nextTick, reactive, ref, watch } from 'vue'
|
|
5
5
|
import { syncStateBundledAndExternal } from '../util.js'
|
|
6
6
|
|
|
7
7
|
// These run against both Vue copies for real: `@poveste/vendors/vue` is the one
|
|
@@ -41,6 +41,82 @@ describe('syncStateBundledAndExternal', () => {
|
|
|
41
41
|
sync.stop()
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
// Each side's walker recurses with its own copy's `isRef`, and the bundled
|
|
45
|
+
// one used to hand its whole subtree to the external walker — so below the
|
|
46
|
+
// first level, bundled state was walked by the wrong copy of Vue. Nothing
|
|
47
|
+
// observable came of it, because `isRef` reads `__v_isRef` rather than an
|
|
48
|
+
// identity and either copy answers for the other's ref; this pins the
|
|
49
|
+
// recursion either way, which is coverage the bridge did not have.
|
|
50
|
+
it('unwraps a ref nested below the first level, on both sides', async () => {
|
|
51
|
+
const { bundled, external, sync } = setup({ count: 0 })
|
|
52
|
+
await settle()
|
|
53
|
+
|
|
54
|
+
bundled.deep = { inner: { value: bundledRef('from bundled') } }
|
|
55
|
+
await settle()
|
|
56
|
+
expect(external.deep.inner.value).toBe('from bundled')
|
|
57
|
+
|
|
58
|
+
external.other = { inner: { value: ref('from external') } }
|
|
59
|
+
await settle()
|
|
60
|
+
expect(bundled.other.inner.value).toBe('from external')
|
|
61
|
+
|
|
62
|
+
sync.stop()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Both walkers rebuilt every object from its own enumerable keys, so a `Date`
|
|
66
|
+
// crossed as `{}` — and the baseline's `isEquivalent`, which refuses to
|
|
67
|
+
// compare anything that is not plain precisely so two distinct `Date`s are
|
|
68
|
+
// never called equal, was handed two empty plain objects instead (#977).
|
|
69
|
+
it('carries a Date across, rather than emptying it', async () => {
|
|
70
|
+
const { bundled, external, sync } = setup({ count: 0 })
|
|
71
|
+
await settle()
|
|
72
|
+
|
|
73
|
+
external.at = new Date(1790071200000)
|
|
74
|
+
await settle()
|
|
75
|
+
|
|
76
|
+
expect(bundled.at).toBeInstanceOf(Date)
|
|
77
|
+
expect(bundled.at.getTime()).toBe(1790071200000)
|
|
78
|
+
|
|
79
|
+
sync.stop()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('carries a Map and a class instance the same way', async () => {
|
|
83
|
+
class Point {
|
|
84
|
+
constructor(public x: number, public y: number) {}
|
|
85
|
+
get len() { return Math.hypot(this.x, this.y) }
|
|
86
|
+
}
|
|
87
|
+
const { bundled, external, sync } = setup({ count: 0 })
|
|
88
|
+
await settle()
|
|
89
|
+
|
|
90
|
+
external.m = new Map([['a', 1]])
|
|
91
|
+
external.p = new Point(3, 4)
|
|
92
|
+
await settle()
|
|
93
|
+
|
|
94
|
+
expect(bundled.m).toBeInstanceOf(Map)
|
|
95
|
+
expect(bundled.m.get('a')).toBe(1)
|
|
96
|
+
// In one realm the reference crosses, so the prototype survives too — which
|
|
97
|
+
// the sandbox bridge cannot promise and does not (#977).
|
|
98
|
+
expect(bundled.p.len).toBe(5)
|
|
99
|
+
|
|
100
|
+
sync.stop()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// The half the walker was destroying upstream of: two different dates must
|
|
104
|
+
// not settle as equivalent, or an edit is dropped rather than mirrored.
|
|
105
|
+
it('mirrors a change from one Date to another', async () => {
|
|
106
|
+
const { bundled, external, sync } = setup({ count: 0 })
|
|
107
|
+
await settle()
|
|
108
|
+
|
|
109
|
+
external.at = new Date(1790071200000)
|
|
110
|
+
await settle()
|
|
111
|
+
|
|
112
|
+
external.at = new Date(0)
|
|
113
|
+
await settle()
|
|
114
|
+
|
|
115
|
+
expect(bundled.at.getTime()).toBe(0)
|
|
116
|
+
|
|
117
|
+
sync.stop()
|
|
118
|
+
})
|
|
119
|
+
|
|
44
120
|
it('stops mirroring once stopped', async () => {
|
|
45
121
|
const { bundled, external, sync } = setup({ count: 0 })
|
|
46
122
|
await settle()
|
|
@@ -385,3 +461,38 @@ describe('keys the sync is told to omit', () => {
|
|
|
385
461
|
sync.stop()
|
|
386
462
|
})
|
|
387
463
|
})
|
|
464
|
+
|
|
465
|
+
describe('a binding Vue has marked raw', () => {
|
|
466
|
+
// Not `setup()` above: that clones its argument, and `structuredClone` is
|
|
467
|
+
// exactly what a mark does not survive.
|
|
468
|
+
function setupWithGraph() {
|
|
469
|
+
const graph = markRaw({ nodes: { deep: 1 } })
|
|
470
|
+
const bundled = bundledReactive<Record<string, any>>({ text: '', graph })
|
|
471
|
+
const external = reactive<Record<string, any>>({ text: '' })
|
|
472
|
+
const sync = syncStateBundledAndExternal(bundled, external)
|
|
473
|
+
return { bundled, external, sync, graph }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
it('crosses between the two Vues as itself, not as a rebuilt copy', async () => {
|
|
477
|
+
const { external, sync, graph } = setupWithGraph()
|
|
478
|
+
await settle()
|
|
479
|
+
|
|
480
|
+
// Both sides live in one realm, so the reference is the useful thing to
|
|
481
|
+
// hand over — and handing it over is what lets the compare settle on
|
|
482
|
+
// identity instead of walking whatever is behind it (#957).
|
|
483
|
+
expect(external.graph).toBe(graph)
|
|
484
|
+
sync.stop()
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('stays that object over later passes, and does not hold up the keys beside it', async () => {
|
|
488
|
+
const { bundled, external, sync, graph } = setupWithGraph()
|
|
489
|
+
await settle()
|
|
490
|
+
|
|
491
|
+
bundled.text = 'a'
|
|
492
|
+
await settle()
|
|
493
|
+
|
|
494
|
+
expect(external.graph).toBe(graph)
|
|
495
|
+
expect(external.text).toBe('a')
|
|
496
|
+
sync.stop()
|
|
497
|
+
})
|
|
498
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { computed, readonly, ref, shallowRef, useTemplateRef } from 'vue'
|
|
3
|
+
import { isWritableBinding } from '../util.js'
|
|
4
|
+
|
|
5
|
+
// `useTemplateRef` returns a readonly ref in a dev build and a writable one in a
|
|
6
|
+
// production build, so this file is the only place the defect in #959 can be
|
|
7
|
+
// caught: vitest runs the dev build, and the e2e suites serve built books where
|
|
8
|
+
// the bug does not exist.
|
|
9
|
+
|
|
10
|
+
describe('isWritableBinding', () => {
|
|
11
|
+
it('rejects the ref useTemplateRef returns, which is what loops the sync', () => {
|
|
12
|
+
expect(isWritableBinding(useTemplateRef('el'))).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('rejects a readonly ref however it was made', () => {
|
|
16
|
+
expect(isWritableBinding(readonly(ref(0)))).toBe(false)
|
|
17
|
+
expect(isWritableBinding(readonly(shallowRef(null)))).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Same rule, and the reason is the same: a control that cannot write is not a
|
|
21
|
+
// control. Called out because it is a behaviour change beyond the defect.
|
|
22
|
+
it('rejects a computed, which could never take the write either', () => {
|
|
23
|
+
expect(isWritableBinding(computed(() => 1))).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// The shape every existing story uses for a template ref, which has to keep
|
|
27
|
+
// working — a story that writes its own state through one still syncs.
|
|
28
|
+
it('accepts the pre-3.5 ref(null) form', () => {
|
|
29
|
+
expect(isWritableBinding(ref(null))).toBe(true)
|
|
30
|
+
expect(isWritableBinding(shallowRef(null))).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('accepts ordinary bindings, ref and plain alike', () => {
|
|
34
|
+
expect(isWritableBinding(ref(0))).toBe(true)
|
|
35
|
+
expect(isWritableBinding({ a: 1 })).toBe(true)
|
|
36
|
+
expect(isWritableBinding('text')).toBe(true)
|
|
37
|
+
expect(isWritableBinding(undefined)).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -52,7 +52,7 @@ function visitVNodes(vnodes: any, externalState: Variant['state'], traversalStat
|
|
|
52
52
|
|
|
53
53
|
for (const key of names) {
|
|
54
54
|
const prop = Array.isArray(declared) ? undefined : declared[key]
|
|
55
|
-
let types
|
|
55
|
+
let types: string[] | undefined
|
|
56
56
|
let defaultValue
|
|
57
57
|
|
|
58
58
|
if (prop) {
|
|
@@ -169,7 +169,7 @@ function normalizeVNodes(vnodes: any) {
|
|
|
169
169
|
return vnodes == null ? [] : [vnodes]
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
const result = []
|
|
172
|
+
const result: any[] = []
|
|
173
173
|
|
|
174
174
|
for (const vnode of vnodes) {
|
|
175
175
|
if (Array.isArray(vnode)) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ComputedRef } from 'vue'
|
|
2
|
+
import { createContext } from '../context.js'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* App mode's contexts. Both are optional, and both were guarded by hand before
|
|
6
|
+
* being able to say so (#981).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The story a variant belongs to. Read with `?.` throughout. */
|
|
10
|
+
export const storyContext = createContext<ComputedRef<any>>('poveste-app-story', '<Story>')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The story's implicit state, absent when the story has none. A variant skips
|
|
14
|
+
* its state sync rather than failing, which is behaviour rather than an
|
|
15
|
+
* oversight — this is the type saying so.
|
|
16
|
+
*/
|
|
17
|
+
export const implicitStateContext = createContext<() => any>('poveste-app-implicit-state', '<Story>')
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
App as _App,
|
|
3
|
+
Component as _Component,
|
|
3
4
|
} from '@poveste/vendors/vue'
|
|
4
5
|
import type {
|
|
5
6
|
App,
|
|
@@ -27,14 +28,19 @@ export function registerGlobalComponents(app: App) {
|
|
|
27
28
|
|
|
28
29
|
app.component('Variant', Variant)
|
|
29
30
|
|
|
30
|
-
for (const key
|
|
31
|
-
app.component(key, wrapControlComponent(
|
|
31
|
+
for (const [key, component] of Object.entries(components)) {
|
|
32
|
+
app.component(key, wrapControlComponent(component))
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
function wrapControlComponent(controlComponent) {
|
|
36
|
+
function wrapControlComponent(controlComponent: (typeof components)[keyof typeof components]) {
|
|
37
|
+
// Which control this is decides at runtime, and its props are the story's untyped
|
|
38
|
+
// attrs, so it renders through Vue's dynamic `Component` rather than a union of every
|
|
39
|
+
// control's props, where a slider would be claimed renderable without `min` (#835).
|
|
40
|
+
const dynamicControl: _Component = controlComponent
|
|
41
|
+
|
|
36
42
|
return defineComponent({
|
|
37
|
-
name: controlComponent.name,
|
|
43
|
+
...controlComponent.name ? { name: controlComponent.name } : {},
|
|
38
44
|
inheritAttrs: false,
|
|
39
45
|
setup(props, { attrs }) {
|
|
40
46
|
const el = ref<HTMLDivElement>()
|
|
@@ -44,7 +50,7 @@ function wrapControlComponent(controlComponent) {
|
|
|
44
50
|
|
|
45
51
|
const state = _reactive({})
|
|
46
52
|
|
|
47
|
-
function applyState(data) {
|
|
53
|
+
function applyState(data: Record<string, unknown>) {
|
|
48
54
|
Object.assign(state, data)
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -55,15 +61,15 @@ function wrapControlComponent(controlComponent) {
|
|
|
55
61
|
|
|
56
62
|
// Slots
|
|
57
63
|
|
|
58
|
-
let newSlotCalls = []
|
|
59
|
-
const slotCalls = ref([])
|
|
64
|
+
let newSlotCalls: Record<string, unknown>[] = []
|
|
65
|
+
const slotCalls = ref<Record<string, unknown>[]>([])
|
|
60
66
|
|
|
61
67
|
function moveSlotContent() {
|
|
62
68
|
slotCalls.value.forEach((props, index) => {
|
|
63
|
-
const renderedEl = slotEl.value
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
while (targetEl.
|
|
69
|
+
const renderedEl = slotEl.value?.querySelector(`[renderslotid="${index}"]`)
|
|
70
|
+
const targetEl = el.value?.querySelector(`[slotid="${index}"]`)
|
|
71
|
+
if (!renderedEl || !targetEl) return
|
|
72
|
+
while (targetEl.lastChild) {
|
|
67
73
|
targetEl.removeChild(targetEl.lastChild)
|
|
68
74
|
}
|
|
69
75
|
targetEl.appendChild(renderedEl)
|
|
@@ -85,11 +91,11 @@ function wrapControlComponent(controlComponent) {
|
|
|
85
91
|
newSlotCalls = []
|
|
86
92
|
},
|
|
87
93
|
render() {
|
|
88
|
-
return _h(
|
|
94
|
+
return _h(dynamicControl, {
|
|
89
95
|
...state,
|
|
90
96
|
key: 'component',
|
|
91
97
|
}, {
|
|
92
|
-
default: (props) => {
|
|
98
|
+
default: (props: Record<string, unknown>) => {
|
|
93
99
|
newSlotCalls.push(props)
|
|
94
100
|
return _h('div', {
|
|
95
101
|
slotId: newSlotCalls.length - 1,
|
|
@@ -124,7 +130,7 @@ function wrapControlComponent(controlComponent) {
|
|
|
124
130
|
ref: 'slotEl',
|
|
125
131
|
}, this.slotCalls.map((props, index) => h('div', {
|
|
126
132
|
renderSlotId: index,
|
|
127
|
-
}, this.$slots
|
|
133
|
+
}, this.$slots['default']?.(props)))),
|
|
128
134
|
]
|
|
129
135
|
},
|
|
130
136
|
})
|
package/src/client/app/host.ts
CHANGED
|
@@ -45,15 +45,17 @@ const PreviewHostRoot = defineComponent({
|
|
|
45
45
|
setup(props) {
|
|
46
46
|
provideRenderContext(props.renderContext)
|
|
47
47
|
|
|
48
|
-
return () =>
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
return () => props.story.file
|
|
49
|
+
? h(props.story.file.component, {
|
|
50
|
+
story: props.story,
|
|
51
|
+
})
|
|
52
|
+
: null
|
|
51
53
|
},
|
|
52
54
|
})
|
|
53
55
|
|
|
54
56
|
export function createPreviewHost(options: PreviewHostOptions) {
|
|
55
|
-
let app: App = null
|
|
56
|
-
let target: HTMLDivElement = null
|
|
57
|
+
let app: App | null = null
|
|
58
|
+
let target: HTMLDivElement | null = null
|
|
57
59
|
|
|
58
60
|
async function mount() {
|
|
59
61
|
const wrappers: Component[] = []
|
|
@@ -103,7 +105,8 @@ export function createPreviewHost(options: PreviewHostOptions) {
|
|
|
103
105
|
const setupApi: Vue3StorySetupApi = {
|
|
104
106
|
app,
|
|
105
107
|
story: options.getStory(),
|
|
106
|
-
variant
|
|
108
|
+
// The setup API declares `variant` optional, not nullable.
|
|
109
|
+
variant: options.getVariant() ?? undefined,
|
|
107
110
|
addWrapper: (wrapper) => {
|
|
108
111
|
wrappers.unshift(wrapper)
|
|
109
112
|
},
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Variant } from '@poveste/shared'
|
|
2
|
-
import
|
|
3
|
-
import { inject, provide } from 'vue'
|
|
2
|
+
import { createContext } from '../context.js'
|
|
4
3
|
|
|
5
4
|
export interface PreviewRenderContext {
|
|
6
5
|
mode: 'mount' | 'render'
|
|
@@ -12,18 +11,39 @@ export interface PreviewRenderContext {
|
|
|
12
11
|
* app realm, where the story view needs all of them mounted.
|
|
13
12
|
*/
|
|
14
13
|
targetVariantId?: string | null
|
|
14
|
+
/**
|
|
15
|
+
* Whether this mount pass owns the live state sync for its variants.
|
|
16
|
+
*
|
|
17
|
+
* False in a sandbox realm, where the render tree beside it already syncs the
|
|
18
|
+
* same `variant.state` against the same story's bindings. Two syncs over one
|
|
19
|
+
* state is two deep watchers walking the whole graph on every edit, and a
|
|
20
|
+
* story holding a large one pays for both: #964 measured the sandbox running
|
|
21
|
+
* three syncs where the app realm runs one.
|
|
22
|
+
*
|
|
23
|
+
* The app realm keeps it. Its mount pass is the only thing that fills
|
|
24
|
+
* `variant.state` there — with no controls slot nothing renders, so nothing
|
|
25
|
+
* else would — and that is what the controls panel builds from.
|
|
26
|
+
*/
|
|
27
|
+
syncState?: boolean
|
|
15
28
|
externalState: Variant['state'] | null
|
|
16
29
|
nextVariantIndex: {
|
|
17
30
|
value: number
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
/*
|
|
35
|
+
* This one was already a typed symbol with its provide and inject together —
|
|
36
|
+
* it was the pattern the other six were measured against (#981). It goes
|
|
37
|
+
* through the shared helper so the package has one shape rather than two,
|
|
38
|
+
* which is the mistake #978 is about, in miniature.
|
|
39
|
+
*/
|
|
40
|
+
const renderContext = createContext<PreviewRenderContext>('poveste-preview-render-context', '<Story>')
|
|
22
41
|
|
|
23
42
|
export function provideRenderContext(value: PreviewRenderContext) {
|
|
24
|
-
provide(
|
|
43
|
+
renderContext.provide(value)
|
|
25
44
|
}
|
|
26
45
|
|
|
46
|
+
/** Null outside a preview, which several call sites read with `?.`. */
|
|
27
47
|
export function useRenderContext() {
|
|
28
|
-
return
|
|
48
|
+
return renderContext.injectOptional()
|
|
29
49
|
}
|
package/src/client/app/util.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ComponentInternalInstance } from 'vue'
|
|
2
|
+
import { applyState, createStateBaseline, isMarkedRaw, isPlainObject } from '@poveste/shared'
|
|
2
3
|
import {
|
|
3
4
|
isRef as _isRef,
|
|
4
5
|
unref as _unref,
|
|
5
6
|
watch as _watch,
|
|
6
7
|
} from '@poveste/vendors/vue'
|
|
7
|
-
import { isRef, unref, watch } from 'vue'
|
|
8
|
+
import { getCurrentInstance, isReadonly, isRef, markRaw, unref, watch } from 'vue'
|
|
8
9
|
|
|
9
|
-
const isObject = val => val !== null && typeof val === 'object'
|
|
10
|
+
const isObject = (val: unknown): val is object => val !== null && typeof val === 'object'
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A value Vue has marked raw is carried by reference rather than rebuilt.
|
|
14
|
+
*
|
|
15
|
+
* Both sides of this bridge live in one realm, so the reference is the useful
|
|
16
|
+
* thing to hand over: the baseline then holds the same identity the next pass
|
|
17
|
+
* reads, `isEquivalent` settles it by `Object.is`, and nothing walks whatever
|
|
18
|
+
* is behind it. Rebuilding it was ten of the twenty traversals a keystroke
|
|
19
|
+
* cost (#957).
|
|
20
|
+
*
|
|
21
|
+
* The bridge that leaves this realm cannot do the same — see `toRawDeep` in
|
|
22
|
+
* `poveste-app`, where the mark does not survive the crossing and the key is
|
|
23
|
+
* dropped instead.
|
|
24
|
+
*/
|
|
11
25
|
/**
|
|
12
26
|
* Using external/user Vue
|
|
13
27
|
*/
|
|
14
|
-
export function toRawDeep(val, seen = new WeakMap()) {
|
|
28
|
+
export function toRawDeep(val: unknown, seen = new WeakMap()): any {
|
|
15
29
|
const unwrappedValue = isRef(val) ? unref(val) : val
|
|
16
30
|
|
|
17
31
|
if (typeof unwrappedValue === 'symbol') {
|
|
@@ -22,12 +36,34 @@ export function toRawDeep(val, seen = new WeakMap()) {
|
|
|
22
36
|
return unwrappedValue
|
|
23
37
|
}
|
|
24
38
|
|
|
39
|
+
if (isMarkedRaw(unwrappedValue)) {
|
|
40
|
+
return unwrappedValue
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
if (seen.has(unwrappedValue)) {
|
|
26
44
|
return seen.get(unwrappedValue)
|
|
27
45
|
}
|
|
28
46
|
|
|
47
|
+
if (!Array.isArray(unwrappedValue) && !isPlainObject(unwrappedValue)) {
|
|
48
|
+
// A `Date`, a `Map`, a class instance. Both sides of this bridge live in
|
|
49
|
+
// one realm, so the reference is the useful thing to hand over — the same
|
|
50
|
+
// answer `copyState` in `@poveste/shared` gives. Rebuilding it as a plain
|
|
51
|
+
// object emptied it: a `Date` has no own enumerable keys, so it arrived as
|
|
52
|
+
// `{}`, and the baseline's `isEquivalent` was handed two of those and
|
|
53
|
+
// called them equivalent (#977).
|
|
54
|
+
//
|
|
55
|
+
// Marked on the way through, which is not decoration. `applyState` writes
|
|
56
|
+
// this into the other Vue's reactive graph, and a reference to something
|
|
57
|
+
// like a Nuxt app instance — which a `<script setup>` binding can be — is
|
|
58
|
+
// then deep-watched: `useNuxtApp()` in a story recursed until the stack
|
|
59
|
+
// gave out. Flattening used to bound that by accident. `markRaw` bounds it
|
|
60
|
+
// deliberately, and is the treatment #974 already gives a value that must
|
|
61
|
+
// cross by reference.
|
|
62
|
+
return markRaw(unwrappedValue)
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
if (Array.isArray(unwrappedValue)) {
|
|
30
|
-
const result = []
|
|
66
|
+
const result: unknown[] = []
|
|
31
67
|
seen.set(unwrappedValue, result)
|
|
32
68
|
result.push(...unwrappedValue.map(value => toRawDeep(value, seen)))
|
|
33
69
|
return result
|
|
@@ -49,7 +85,7 @@ function toRawObject(obj: Record<any, any>, target: Record<any, any>, seen = new
|
|
|
49
85
|
/**
|
|
50
86
|
* Using bundled Vue
|
|
51
87
|
*/
|
|
52
|
-
export function _toRawDeep(val, seen = new WeakMap()) {
|
|
88
|
+
export function _toRawDeep(val: unknown, seen = new WeakMap()): any {
|
|
53
89
|
const unwrappedValue = _isRef(val) ? _unref(val) : val
|
|
54
90
|
|
|
55
91
|
if (typeof unwrappedValue === 'symbol') {
|
|
@@ -60,12 +96,27 @@ export function _toRawDeep(val, seen = new WeakMap()) {
|
|
|
60
96
|
return unwrappedValue
|
|
61
97
|
}
|
|
62
98
|
|
|
99
|
+
if (isMarkedRaw(unwrappedValue)) {
|
|
100
|
+
return unwrappedValue
|
|
101
|
+
}
|
|
102
|
+
|
|
63
103
|
if (seen.has(unwrappedValue)) {
|
|
64
104
|
return seen.get(unwrappedValue)
|
|
65
105
|
}
|
|
66
106
|
|
|
107
|
+
if (!Array.isArray(unwrappedValue) && !isPlainObject(unwrappedValue)) {
|
|
108
|
+
// A `Date`, a `Map`, a class instance. Both sides of this bridge live in
|
|
109
|
+
// one realm, so the reference is the useful thing to hand over — the same
|
|
110
|
+
// answer given to a marked value just above, and the same one `copyState`
|
|
111
|
+
// in `@poveste/shared` gives. Rebuilding it as a plain object emptied it: a
|
|
112
|
+
// `Date` has no own enumerable keys, so it arrived as `{}`, and the
|
|
113
|
+
// baseline's `isEquivalent` was handed two of those and called them
|
|
114
|
+
// equivalent (#977). Marked for the reason given on the walker above.
|
|
115
|
+
return markRaw(unwrappedValue)
|
|
116
|
+
}
|
|
117
|
+
|
|
67
118
|
if (Array.isArray(unwrappedValue)) {
|
|
68
|
-
const result = []
|
|
119
|
+
const result: unknown[] = []
|
|
69
120
|
seen.set(unwrappedValue, result)
|
|
70
121
|
result.push(...unwrappedValue.map(value => _toRawDeep(value, seen)))
|
|
71
122
|
return result
|
|
@@ -78,9 +129,22 @@ export function _toRawDeep(val, seen = new WeakMap()) {
|
|
|
78
129
|
}
|
|
79
130
|
}
|
|
80
131
|
|
|
132
|
+
/**
|
|
133
|
+
* `_toRawDeep`, not `toRawDeep`: this is the bundled Vue's walker, and handing
|
|
134
|
+
* the subtree to the external one meant only the top level was ever walked by
|
|
135
|
+
* the copy of Vue that owns it.
|
|
136
|
+
*
|
|
137
|
+
* Nothing observable changes today, which is why it survived. The two walkers
|
|
138
|
+
* differ only in whose `isRef`/`unref` they call, and `isRef` is a read of
|
|
139
|
+
* `__v_isRef` — a property, not an identity — so either copy answers correctly
|
|
140
|
+
* for a ref the other created. It is fixed as a trap rather than as a defect:
|
|
141
|
+
* the next rule that lands on one walker and not the other would apply to the
|
|
142
|
+
* first level of bundled state and to nothing beneath it, and the diff would
|
|
143
|
+
* look right.
|
|
144
|
+
*/
|
|
81
145
|
function _toRawObject(obj: Record<any, any>, target: Record<any, any>, seen = new WeakMap()) {
|
|
82
146
|
Object.keys(obj).forEach((key) => {
|
|
83
|
-
target[key] =
|
|
147
|
+
target[key] = _toRawDeep(obj[key], seen)
|
|
84
148
|
})
|
|
85
149
|
}
|
|
86
150
|
|
|
@@ -103,7 +167,7 @@ function without(state: Record<string, any>, omit: string[]): Record<string, any
|
|
|
103
167
|
* @param externalState Reactive state created with the external/user Vue
|
|
104
168
|
* @param omit Keys neither side may learn from the other
|
|
105
169
|
*/
|
|
106
|
-
export function syncStateBundledAndExternal(bundledState, externalState, omit: string[] = []) {
|
|
170
|
+
export function syncStateBundledAndExternal(bundledState: Record<string, any>, externalState: Record<string, any>, omit: string[] = []) {
|
|
107
171
|
// Each side is asked only for what *it* changed, and that is all that crosses.
|
|
108
172
|
//
|
|
109
173
|
// Both watchers used to mirror the whole of their side. That works while the
|
|
@@ -146,3 +210,34 @@ export function syncStateBundledAndExternal(bundledState, externalState, omit: s
|
|
|
146
210
|
},
|
|
147
211
|
}
|
|
148
212
|
}
|
|
213
|
+
|
|
214
|
+
/** The component being set up. Throws outside `setup`, where Vue returns null. */
|
|
215
|
+
/**
|
|
216
|
+
* Whether a `<script setup>` binding can be story state a control drives.
|
|
217
|
+
*
|
|
218
|
+
* A readonly ref cannot: the write-back is refused, so the two sides of the
|
|
219
|
+
* bridge never agree on it and the sync re-walks forever (#959). `useTemplateRef`
|
|
220
|
+
* returns one — in dev builds only, which is why a built book is quiet and the
|
|
221
|
+
* mode story authors work in is not.
|
|
222
|
+
*
|
|
223
|
+
* It has to be asked of the ref rather than of what the ref holds. The guard
|
|
224
|
+
* beside this one tests the value, and a value test cannot classify a template
|
|
225
|
+
* ref at all: at setup time it holds `null`, and what it will hold later is not
|
|
226
|
+
* knowable from here. Readonly-ness is a property of the ref itself and is
|
|
227
|
+
* settled by the time the binding is registered.
|
|
228
|
+
*
|
|
229
|
+
* A `computed` is caught by the same rule and for the same reason — it is a
|
|
230
|
+
* derived value, and a control that cannot write is not a control. Before this
|
|
231
|
+
* it appeared in the panel and warned when written.
|
|
232
|
+
*/
|
|
233
|
+
export function isWritableBinding(value: unknown): boolean {
|
|
234
|
+
return !(isRef(value) && isReadonly(value))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function useInstance(name: string): ComponentInternalInstance {
|
|
238
|
+
const vm = getCurrentInstance()
|
|
239
|
+
if (!vm) {
|
|
240
|
+
throw new Error(`[poveste] <${name}> can only be set up inside a component`)
|
|
241
|
+
}
|
|
242
|
+
return vm
|
|
243
|
+
}
|