@poveste/plugin-vue 0.18.3 → 0.19.1
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/dist/THIRD_PARTY_NOTICES.md +3 -0
- package/dist/bundled/client/app/host.js +11 -10
- package/dist/commands/generate-story.client.js +3 -1
- package/dist/index.node.js +27 -0
- package/dist/util/vapor.d.ts +30 -0
- package/dist/util/vapor.js +59 -0
- package/package.json +10 -7
- package/src/client/app/host.ts +9 -0
- package/src/commands/generate-story.client.ts +3 -1
- package/src/index.node.ts +29 -0
- package/src/util/vapor.ts +65 -0
- package/dist/util/list-components.spec.d.ts +0 -1
- package/dist/util/list-components.spec.js +0 -46
- package/src/client/__tests__/codegen-attrs.spec.ts +0 -149
- package/src/client/__tests__/context.spec.ts +0 -50
- package/src/client/app/__tests__/auto-props.spec.ts +0 -49
- package/src/client/app/__tests__/preview-variants.spec.ts +0 -76
- package/src/client/app/__tests__/state-sync.spec.ts +0 -498
- package/src/client/app/__tests__/writable-binding.spec.ts +0 -39
- package/src/client/server/__tests__/nesting-errors.spec.ts +0 -48
- package/src/util/list-components.spec.ts +0 -54
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { inferredType, passedValue } from '../auto-props.js'
|
|
3
|
-
|
|
4
|
-
/*
|
|
5
|
-
* A production build erases the runtime types Vue infers from a type-only
|
|
6
|
-
* `defineProps`, so the declaration says nothing and the value has to (#490).
|
|
7
|
-
*/
|
|
8
|
-
describe('the type a value implies', () => {
|
|
9
|
-
it.each([
|
|
10
|
-
['a string', 'hello', 'string'],
|
|
11
|
-
['a number', 42, 'number'],
|
|
12
|
-
['zero', 0, 'number'],
|
|
13
|
-
['a boolean', false, 'boolean'],
|
|
14
|
-
['an array', [1, 2], 'array'],
|
|
15
|
-
['an object', { a: 1 }, 'object'],
|
|
16
|
-
])('reads %s', (_, value, expected) => {
|
|
17
|
-
expect(inferredType(value)).toBe(expected)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
// Nothing to go on, and guessing would pick a control the prop cannot hold.
|
|
21
|
-
// A factory default arrives here as the function it is, which is why nothing
|
|
22
|
-
// upstream has to strip it first.
|
|
23
|
-
it.each([
|
|
24
|
-
['null', null],
|
|
25
|
-
['undefined', undefined],
|
|
26
|
-
['a factory default', () => []],
|
|
27
|
-
])('declines to guess from %s', (_, value) => {
|
|
28
|
-
expect(inferredType(value)).toBe('unknown')
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
describe('the value a vnode passed', () => {
|
|
33
|
-
it('finds a prop under the name it was declared with', () => {
|
|
34
|
-
expect(passedValue({ props: { label: 'x' } }, 'label')).toBe('x')
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
// `vnode.type.props` is keyed as declared, `vnode.props` as authored.
|
|
38
|
-
it('finds a multi-word prop written in kebab case', () => {
|
|
39
|
-
expect(passedValue({ props: { 'my-label': 'x' } }, 'myLabel')).toBe('x')
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('prefers the declared spelling when both are present', () => {
|
|
43
|
-
expect(passedValue({ props: { 'myLabel': 'camel', 'my-label': 'kebab' } }, 'myLabel')).toBe('camel')
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('is undefined when the component was given no props at all', () => {
|
|
47
|
-
expect(passedValue({}, 'label')).toBeUndefined()
|
|
48
|
-
})
|
|
49
|
-
})
|
|
@@ -1,76 +0,0 @@
|
|
|
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,498 +0,0 @@
|
|
|
1
|
-
import { autoPropsStateKeys } from '@poveste/shared'
|
|
2
|
-
import { nextTick as bundledNextTick, reactive as bundledReactive, ref as bundledRef } from '@poveste/vendors/vue'
|
|
3
|
-
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import { markRaw, nextTick, reactive, ref, watch } from 'vue'
|
|
5
|
-
import { syncStateBundledAndExternal } from '../util.js'
|
|
6
|
-
|
|
7
|
-
// These run against both Vue copies for real: `@poveste/vendors/vue` is the one
|
|
8
|
-
// poveste bundles, `vue` is the one a user's story would run on. That is the
|
|
9
|
-
// whole point — the sync exists because the two have separate reactivity graphs
|
|
10
|
-
// and separate schedulers, and nothing about it can be tested with one of them.
|
|
11
|
-
|
|
12
|
-
// Each side flushes on its own scheduler and a write can bounce between them a
|
|
13
|
-
// few times before settling, so drain both alternately rather than once.
|
|
14
|
-
async function settle() {
|
|
15
|
-
for (let i = 0; i < 6; i++) {
|
|
16
|
-
await nextTick()
|
|
17
|
-
await bundledNextTick()
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function setup(initial: Record<string, any>, omit: string[] = []) {
|
|
22
|
-
const bundled = bundledReactive(structuredClone(initial))
|
|
23
|
-
const external = reactive(structuredClone(initial))
|
|
24
|
-
const sync = syncStateBundledAndExternal(bundled, external, omit)
|
|
25
|
-
return { bundled, external, sync }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe('syncStateBundledAndExternal', () => {
|
|
29
|
-
it('mirrors an edit in each direction', async () => {
|
|
30
|
-
const { bundled, external, sync } = setup({ count: 0 })
|
|
31
|
-
await settle()
|
|
32
|
-
|
|
33
|
-
external.count = 1
|
|
34
|
-
await settle()
|
|
35
|
-
expect(bundled.count).toBe(1)
|
|
36
|
-
|
|
37
|
-
bundled.count = 2
|
|
38
|
-
await settle()
|
|
39
|
-
expect(external.count).toBe(2)
|
|
40
|
-
|
|
41
|
-
sync.stop()
|
|
42
|
-
})
|
|
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
|
-
|
|
120
|
-
it('stops mirroring once stopped', async () => {
|
|
121
|
-
const { bundled, external, sync } = setup({ count: 0 })
|
|
122
|
-
await settle()
|
|
123
|
-
sync.stop()
|
|
124
|
-
|
|
125
|
-
external.count = 1
|
|
126
|
-
await settle()
|
|
127
|
-
expect(bundled.count).toBe(0)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
// The #95 regression. `applyState` iterates the incoming keys, so it has no
|
|
131
|
-
// way to express "this key is gone" — the write it performs for a removal
|
|
132
|
-
// changes nothing on the far side, and the far side's watcher never fires.
|
|
133
|
-
//
|
|
134
|
-
// That matters because the two watchers coordinate through a `syncing` boolean
|
|
135
|
-
// meaning "ignore the next firing, it is my own echo", and the counterpart
|
|
136
|
-
// firing is what clears it. No counterpart, no clear: the flag stayed set, and
|
|
137
|
-
// the next edit was mistaken for an echo and dropped on the floor.
|
|
138
|
-
//
|
|
139
|
-
// Worth being exact about how bad this was in the shipped app, because it is
|
|
140
|
-
// the reason the issue had no repro: it never bit here. Vue stories always
|
|
141
|
-
// carry `_hPropState` and `_hPropDefs`, and `applyState` sent every
|
|
142
|
-
// `_h`-prefixed key down the plain-assignment branch, so those two were
|
|
143
|
-
// re-identified on every apply and a counterpart firing was guaranteed. The
|
|
144
|
-
// invariant held by luck. These tests use a state without that bookkeeping,
|
|
145
|
-
// which is what the function actually promises to handle — and which is the
|
|
146
|
-
// shape the sandbox bridge and `plugin-svelte` were relying on luck for too.
|
|
147
|
-
it('does not drop the edit after a removal it cannot mirror', async () => {
|
|
148
|
-
const { bundled, external, sync } = setup({ count: 0, gone: 1 })
|
|
149
|
-
await settle()
|
|
150
|
-
|
|
151
|
-
delete external.gone
|
|
152
|
-
await settle()
|
|
153
|
-
|
|
154
|
-
external.count = 1
|
|
155
|
-
await settle()
|
|
156
|
-
expect(bundled.count).toBe(1)
|
|
157
|
-
|
|
158
|
-
sync.stop()
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
it('does not drop the edit after a nested removal it cannot mirror', async () => {
|
|
162
|
-
// The same shape one level down, and the more plausible one: a story that
|
|
163
|
-
// drops an entry out of a map in state, then touches a control.
|
|
164
|
-
const { bundled, external, sync } = setup({ count: 0, items: { a: 1, b: 2 } })
|
|
165
|
-
await settle()
|
|
166
|
-
|
|
167
|
-
delete external.items.b
|
|
168
|
-
await settle()
|
|
169
|
-
|
|
170
|
-
external.count = 1
|
|
171
|
-
await settle()
|
|
172
|
-
expect(bundled.count).toBe(1)
|
|
173
|
-
|
|
174
|
-
sync.stop()
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it('does not drop the edit after a change that nets out to nothing', async () => {
|
|
178
|
-
const { bundled, external, sync } = setup({ count: 0, list: [1, 2] })
|
|
179
|
-
await settle()
|
|
180
|
-
|
|
181
|
-
external.list.push(9)
|
|
182
|
-
external.list.pop()
|
|
183
|
-
await settle()
|
|
184
|
-
|
|
185
|
-
external.count = 1
|
|
186
|
-
await settle()
|
|
187
|
-
expect(bundled.count).toBe(1)
|
|
188
|
-
|
|
189
|
-
sync.stop()
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('leaves object identity alone on both sides when a sync changes nothing', async () => {
|
|
193
|
-
// The other half of the fix, and the reason the flag can now be trusted.
|
|
194
|
-
// `toRawDeep` rebuilds every object it passes, so mirroring `count` used to
|
|
195
|
-
// drop a fresh `nested` and `list` onto the receiving side as well, and each
|
|
196
|
-
// of those re-triggered its watchers. Every sync was a burst of firings that
|
|
197
|
-
// happened to keep the count even; none of them meant anything.
|
|
198
|
-
const { bundled, external, sync } = setup({ count: 0, nested: { a: 1 }, list: [1, 2] })
|
|
199
|
-
await settle()
|
|
200
|
-
|
|
201
|
-
const externalNested = external.nested
|
|
202
|
-
const externalList = external.list
|
|
203
|
-
const bundledNested = bundled.nested
|
|
204
|
-
const bundledList = bundled.list
|
|
205
|
-
|
|
206
|
-
external.count = 1
|
|
207
|
-
await settle()
|
|
208
|
-
|
|
209
|
-
expect(bundled.count).toBe(1)
|
|
210
|
-
expect(external.nested).toBe(externalNested)
|
|
211
|
-
expect(external.list).toBe(externalList)
|
|
212
|
-
// The receiving side is the one that used to get churned.
|
|
213
|
-
expect(bundled.nested).toBe(bundledNested)
|
|
214
|
-
expect(bundled.list).toBe(bundledList)
|
|
215
|
-
|
|
216
|
-
sync.stop()
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
// The #96 regression, and the reason the `syncing` flag is gone rather than
|
|
220
|
-
// repaired. Both watchers mirrored the whole of their side, so the second one
|
|
221
|
-
// to fire carried its own edit *and* its copy of the key the first side had
|
|
222
|
-
// just changed — one edit stale, because the mirror had not reached it yet.
|
|
223
|
-
// Last writer won, and it won across every key rather than just its own.
|
|
224
|
-
it('keeps both edits when the two sides change in the same tick', async () => {
|
|
225
|
-
const { bundled, external, sync } = setup({ a: 0, b: 0 })
|
|
226
|
-
await settle()
|
|
227
|
-
|
|
228
|
-
external.a = 1
|
|
229
|
-
bundled.b = 1
|
|
230
|
-
await settle()
|
|
231
|
-
|
|
232
|
-
expect({ a: external.a, b: external.b }).toEqual({ a: 1, b: 1 })
|
|
233
|
-
expect({ a: bundled.a, b: bundled.b }).toEqual({ a: 1, b: 1 })
|
|
234
|
-
|
|
235
|
-
sync.stop()
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
it('keeps both edits when the two sides change in the same tick, bundled first', async () => {
|
|
239
|
-
// Which watcher happens to flush first decided who lost, so pin both orders.
|
|
240
|
-
const { bundled, external, sync } = setup({ a: 0, b: 0 })
|
|
241
|
-
await settle()
|
|
242
|
-
|
|
243
|
-
bundled.b = 1
|
|
244
|
-
external.a = 1
|
|
245
|
-
await settle()
|
|
246
|
-
|
|
247
|
-
expect({ a: external.a, b: external.b }).toEqual({ a: 1, b: 1 })
|
|
248
|
-
expect({ a: bundled.a, b: bundled.b }).toEqual({ a: 1, b: 1 })
|
|
249
|
-
|
|
250
|
-
sync.stop()
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
it('keeps both edits when they land in different keys of the same object', async () => {
|
|
254
|
-
// The nested form: two entries of one map, which whole-object mirroring
|
|
255
|
-
// could not separate even in principle.
|
|
256
|
-
const { bundled, external, sync } = setup({ items: { a: 0, b: 0 } })
|
|
257
|
-
await settle()
|
|
258
|
-
|
|
259
|
-
external.items.a = 1
|
|
260
|
-
bundled.items.b = 1
|
|
261
|
-
await settle()
|
|
262
|
-
|
|
263
|
-
expect(external.items).toEqual({ a: 1, b: 1 })
|
|
264
|
-
expect(bundled.items).toEqual({ a: 1, b: 1 })
|
|
265
|
-
|
|
266
|
-
sync.stop()
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
it('keeps a concurrent edit made while the other side is mid-flight', async () => {
|
|
270
|
-
// Not the same tick, and it passed before the fix too — kept because it
|
|
271
|
-
// pins the interleaving the flag was most delicate about: the far side edits
|
|
272
|
-
// after a mirror has landed but before its echo has been observed.
|
|
273
|
-
const { bundled, external, sync } = setup({ a: 0, b: 0 })
|
|
274
|
-
await settle()
|
|
275
|
-
|
|
276
|
-
external.a = 1
|
|
277
|
-
await nextTick()
|
|
278
|
-
bundled.b = 1
|
|
279
|
-
await settle()
|
|
280
|
-
|
|
281
|
-
expect({ a: external.a, b: external.b }).toEqual({ a: 1, b: 1 })
|
|
282
|
-
expect({ a: bundled.a, b: bundled.b }).toEqual({ a: 1, b: 1 })
|
|
283
|
-
|
|
284
|
-
sync.stop()
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
it('mirrors an edit made to a key the other side removed', async () => {
|
|
288
|
-
// A removal cannot cross, so the two sides disagree about that key from then
|
|
289
|
-
// on. The baseline must not mistake that standing disagreement for a change
|
|
290
|
-
// and start replaying it — nor let it mask the next real edit.
|
|
291
|
-
const { bundled, external, sync } = setup({ count: 0, gone: 1 })
|
|
292
|
-
await settle()
|
|
293
|
-
|
|
294
|
-
delete external.gone
|
|
295
|
-
await settle()
|
|
296
|
-
|
|
297
|
-
bundled.count = 5
|
|
298
|
-
await settle()
|
|
299
|
-
expect(external.count).toBe(5)
|
|
300
|
-
|
|
301
|
-
external.count = 6
|
|
302
|
-
await settle()
|
|
303
|
-
expect(bundled.count).toBe(6)
|
|
304
|
-
|
|
305
|
-
sync.stop()
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
it('does not let a mirrored array alias the baseline', async () => {
|
|
309
|
-
// An array always crosses whole — `applyState` assigns it rather than
|
|
310
|
-
// merging — so the receiving side ends up holding the very object the
|
|
311
|
-
// baseline recorded. Reactive writes go through to the raw target, so
|
|
312
|
-
// without a copy the next `push` lands in the baseline as well, and the sync
|
|
313
|
-
// reads the edit back as already agreed and drops it.
|
|
314
|
-
const { bundled, external, sync } = setup({ list: [1, 2] })
|
|
315
|
-
await settle()
|
|
316
|
-
|
|
317
|
-
external.list = [1, 2, 3]
|
|
318
|
-
await settle()
|
|
319
|
-
expect(bundled.list).toEqual([1, 2, 3])
|
|
320
|
-
|
|
321
|
-
bundled.list.push(4)
|
|
322
|
-
await settle()
|
|
323
|
-
expect(external.list).toEqual([1, 2, 3, 4])
|
|
324
|
-
|
|
325
|
-
sync.stop()
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
it('does not let a mirrored object alias the baseline', async () => {
|
|
329
|
-
// Same hazard by the other route into the wholesale branch: a key the far
|
|
330
|
-
// side does not have yet is assigned rather than merged.
|
|
331
|
-
const { bundled, external, sync } = setup({ count: 0 })
|
|
332
|
-
await settle()
|
|
333
|
-
|
|
334
|
-
external.added = { a: 1 }
|
|
335
|
-
await settle()
|
|
336
|
-
expect(bundled.added).toEqual({ a: 1 })
|
|
337
|
-
|
|
338
|
-
bundled.added.a = 2
|
|
339
|
-
await settle()
|
|
340
|
-
expect(external.added.a).toBe(2)
|
|
341
|
-
|
|
342
|
-
sync.stop()
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
it('mirrors a write the story makes in reaction to a control edit', async () => {
|
|
346
|
-
// The realistic form of #96, and the one a book actually hits: a story that
|
|
347
|
-
// derives one state key from another. Editing `a` in the controls panel is
|
|
348
|
-
// one edit, but the story answering it with `b` is a second, and it lands
|
|
349
|
-
// while the first is still in flight — so the two sides are changing at the
|
|
350
|
-
// same time without anyone having to do anything unusual.
|
|
351
|
-
//
|
|
352
|
-
// The story's watcher is installed before the sync here, and that ordering
|
|
353
|
-
// is the whole of it: Vue flushes by creation order, so the story's write
|
|
354
|
-
// lands before the sync watcher runs and rides in on the very firing the
|
|
355
|
-
// flag was waiting to discard. Install the sync first and the same code
|
|
356
|
-
// survived, which is why this never showed up as a reliable bug.
|
|
357
|
-
//
|
|
358
|
-
// Both orders occur. `Variant` attaches its sync in `setup()`, before the
|
|
359
|
-
// story mounts; `RenderStory` tears the sync down and re-attaches it when
|
|
360
|
-
// the variant changes, by which time the story's watchers are long since
|
|
361
|
-
// registered. A flag whose correctness turns on which of those you got is
|
|
362
|
-
// not correct, it is lucky.
|
|
363
|
-
const bundled = bundledReactive({ a: 0, b: 0 })
|
|
364
|
-
const external = reactive({ a: 0, b: 0 })
|
|
365
|
-
|
|
366
|
-
const stop = watch(() => external.a, (value) => {
|
|
367
|
-
external.b = value * 2
|
|
368
|
-
})
|
|
369
|
-
const sync = syncStateBundledAndExternal(bundled, external)
|
|
370
|
-
await settle()
|
|
371
|
-
|
|
372
|
-
bundled.a = 1
|
|
373
|
-
await settle()
|
|
374
|
-
|
|
375
|
-
expect(external.b).toBe(2)
|
|
376
|
-
expect(bundled.b).toBe(2)
|
|
377
|
-
|
|
378
|
-
stop()
|
|
379
|
-
sync.stop()
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
it('mirrors only what moved without taking the neighbouring keys with it', async () => {
|
|
383
|
-
// Sending less than the whole object is what stops the two sides clobbering
|
|
384
|
-
// each other, and it is only safe as far as `applyState` merges — one level,
|
|
385
|
-
// and never for an `_h` key, which it replaces outright for the sandbox's
|
|
386
|
-
// sake. Narrow past either and the subset lands *as* the object.
|
|
387
|
-
//
|
|
388
|
-
// Every Vue story carries `_hPropState`, so getting that wrong empties the
|
|
389
|
-
// auto-props of every story in the book rather than failing somewhere quiet.
|
|
390
|
-
const initial = { _hPropState: { a: 1, b: 2 }, deep: { inner: { x: 1, y: 2 } } }
|
|
391
|
-
const bundled = bundledReactive(structuredClone(initial))
|
|
392
|
-
const external = reactive(structuredClone(initial))
|
|
393
|
-
const sync = syncStateBundledAndExternal(bundled, external)
|
|
394
|
-
await settle()
|
|
395
|
-
|
|
396
|
-
external._hPropState.a = 9
|
|
397
|
-
external.deep.inner.x = 9
|
|
398
|
-
await settle()
|
|
399
|
-
|
|
400
|
-
expect(bundled._hPropState).toEqual({ a: 9, b: 2 })
|
|
401
|
-
expect(bundled.deep.inner).toEqual({ x: 9, y: 2 })
|
|
402
|
-
|
|
403
|
-
sync.stop()
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
it('mirrors many edits in a row without falling behind', async () => {
|
|
407
|
-
const { bundled, external, sync } = setup({ count: 0 })
|
|
408
|
-
await settle()
|
|
409
|
-
|
|
410
|
-
for (let i = 1; i <= 10; i++) {
|
|
411
|
-
external.count = i
|
|
412
|
-
await settle()
|
|
413
|
-
expect(bundled.count).toBe(i)
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
sync.stop()
|
|
417
|
-
})
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
/*
|
|
421
|
-
* A variant's state is synced with the story's implicit state, which every
|
|
422
|
-
* variant shares — so anything that crosses reaches all of them. `_hPropState`
|
|
423
|
-
* is keyed by a component's index within one variant, so it must not (#473).
|
|
424
|
-
*/
|
|
425
|
-
describe('keys the sync is told to omit', () => {
|
|
426
|
-
it('does not carry an auto-props edit to the other side', async () => {
|
|
427
|
-
const { bundled, external, sync } = setup({ _hPropState: {}, label: 'a' }, autoPropsStateKeys)
|
|
428
|
-
await settle()
|
|
429
|
-
|
|
430
|
-
external._hPropState = { 0: { name: 'Bender' } }
|
|
431
|
-
await settle()
|
|
432
|
-
|
|
433
|
-
expect(bundled._hPropState).toEqual({})
|
|
434
|
-
|
|
435
|
-
sync.stop()
|
|
436
|
-
})
|
|
437
|
-
|
|
438
|
-
it('goes on carrying everything else', async () => {
|
|
439
|
-
const { bundled, external, sync } = setup({ _hPropState: {}, label: 'a' }, autoPropsStateKeys)
|
|
440
|
-
await settle()
|
|
441
|
-
|
|
442
|
-
external.label = 'edited'
|
|
443
|
-
await settle()
|
|
444
|
-
|
|
445
|
-
expect(bundled.label).toBe('edited')
|
|
446
|
-
|
|
447
|
-
sync.stop()
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
// The reason the sync exists: a variant with no `initState` picks up the
|
|
451
|
-
// story's own state, and omitting the bookkeeping must not cost that.
|
|
452
|
-
it('still carries a key the story owns', async () => {
|
|
453
|
-
const { bundled, external, sync } = setup({}, autoPropsStateKeys)
|
|
454
|
-
await settle()
|
|
455
|
-
|
|
456
|
-
bundled.fromStory = 'yes'
|
|
457
|
-
await settle()
|
|
458
|
-
|
|
459
|
-
expect(external.fromStory).toBe('yes')
|
|
460
|
-
|
|
461
|
-
sync.stop()
|
|
462
|
-
})
|
|
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
|
-
})
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
})
|