-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Core: Refactor phases to run in order loading
-> rendering
-> playing
#28431
Merged
Merged
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ec3c604
Refactor phases to always run in order loading -> rendering -> playing
kasperpeulen 771b92b
Add extra test
kasperpeulen a96a4f1
Remove loading screen at the right time
kasperpeulen b34500d
Fix build error
kasperpeulen de38fa6
Adjust tests
kasperpeulen cbade14
Merge branch 'next' into kasper/phases
kasperpeulen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
// @vitest-environment happy-dom | ||
import { describe, it, expect, vi } from 'vitest'; | ||
import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
import { Channel } from '@storybook/core/channels'; | ||
import type { PreparedStory, Renderer, StoryContext, StoryIndexEntry } from '@storybook/core/types'; | ||
import type { StoryStore } from '../../store'; | ||
|
@@ -26,6 +26,11 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); | |
|
||
window.location = { reload: vi.fn() } as any; | ||
|
||
const mountSpy = vi.fn(async (context) => { | ||
await context.renderToCanvas(); | ||
return context.canvas; | ||
}); | ||
|
||
const buildStory = (overrides: Partial<PreparedStory> = {}): PreparedStory => | ||
({ | ||
id: 'id', | ||
|
@@ -36,12 +41,7 @@ const buildStory = (overrides: Partial<PreparedStory> = {}): PreparedStory => | |
applyBeforeEach: vi.fn(), | ||
unboundStoryFn: vi.fn(), | ||
playFunction: vi.fn(), | ||
mount: vi.fn((context: StoryContext) => { | ||
return async () => { | ||
await context.renderToCanvas(); | ||
return context.canvas; | ||
}; | ||
}), | ||
mount: (context: StoryContext) => () => mountSpy(context), | ||
...overrides, | ||
}) as any; | ||
|
||
|
@@ -53,6 +53,10 @@ const buildStore = (overrides: Partial<StoryStore<Renderer>> = {}): StoryStore<R | |
...overrides, | ||
}) as any; | ||
|
||
beforeEach(() => { | ||
vi.restoreAllMocks(); | ||
}); | ||
|
||
describe('StoryRender', () => { | ||
it('does run play function if passed autoplay=true', async () => { | ||
const story = buildStory(); | ||
|
@@ -133,12 +137,7 @@ describe('StoryRender', () => { | |
}); | ||
|
||
it('calls mount if play function does not destructure mount', async () => { | ||
const actualMount = vi.fn(async (context) => { | ||
await context.renderToCanvas(); | ||
return {}; | ||
}); | ||
const story = buildStory({ | ||
mount: (context) => () => actualMount(context) as any, | ||
playFunction: () => {}, | ||
}); | ||
const render = new StoryRender( | ||
|
@@ -153,17 +152,14 @@ describe('StoryRender', () => { | |
); | ||
|
||
await render.renderToElement({} as any); | ||
expect(actualMount).toHaveBeenCalled(); | ||
expect(mountSpy).toHaveBeenCalledOnce(); | ||
}); | ||
|
||
it('does not call mount if play function destructures mount', async () => { | ||
const actualMount = vi.fn(async (context) => { | ||
await context.renderToCanvas(); | ||
return context.canvas; | ||
}); | ||
it('does not call mount twice if mount called in play function', async () => { | ||
const story = buildStory({ | ||
mount: (context) => () => actualMount(context) as any, | ||
playFunction: ({ mount }) => {}, | ||
playFunction: async ({ mount }) => { | ||
await mount(); | ||
}, | ||
}); | ||
const render = new StoryRender( | ||
new Channel({}), | ||
|
@@ -177,16 +173,11 @@ describe('StoryRender', () => { | |
); | ||
|
||
await render.renderToElement({} as any); | ||
expect(actualMount).not.toHaveBeenCalled(); | ||
expect(mountSpy).toHaveBeenCalledOnce(); | ||
}); | ||
|
||
it('errors if play function calls mount without destructuring', async () => { | ||
const actualMount = vi.fn(async (context) => { | ||
await context.renderToCanvas(); | ||
return {}; | ||
}); | ||
const story = buildStory({ | ||
mount: (context) => () => actualMount(context) as any, | ||
playFunction: async (context) => { | ||
await context.mount(); | ||
}, | ||
|
@@ -207,16 +198,40 @@ describe('StoryRender', () => { | |
expect(view.showException).toHaveBeenCalled(); | ||
}); | ||
|
||
it('errors if play function destructures mount but does not call it', async () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎉 |
||
const story = buildStory({ | ||
playFunction: async ({ mount }) => { | ||
// forget to call mount | ||
}, | ||
}); | ||
const view = { showException: vi.fn() }; | ||
const render = new StoryRender( | ||
new Channel({}), | ||
buildStore(), | ||
vi.fn() as any, | ||
view as any, | ||
entry.id, | ||
'story', | ||
{ autoplay: true }, | ||
story | ||
); | ||
|
||
await render.renderToElement({} as any); | ||
expect(view.showException).toHaveBeenCalled(); | ||
}); | ||
|
||
it('enters rendering phase during play if play function calls mount', async () => { | ||
const actualMount = vi.fn(async (context) => { | ||
await context.renderToCanvas(); | ||
return {}; | ||
expect(render.phase).toBe('rendering'); | ||
return context.canvas; | ||
}); | ||
const story = buildStory({ | ||
mount: (context) => () => actualMount(context) as any, | ||
playFunction: ({ mount }) => { | ||
playFunction: async ({ mount }) => { | ||
expect(render.phase).toBe('loading'); | ||
await mount(); | ||
expect(render.phase).toBe('playing'); | ||
mount(); | ||
}, | ||
}); | ||
const render = new StoryRender( | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,7 +9,7 @@ import type { StoryStore } from '../../store'; | |
import type { Render, RenderType } from './Render'; | ||
import { PREPARE_ABORTED } from './Render'; | ||
import { mountDestructured } from './mount-utils'; | ||
import { MountMustBeDestructuredError } from '@storybook/core-events/preview-errors'; | ||
import { MountMustBeDestructuredError, NoStoryMountedError } from '@storybook/core/preview-errors'; | ||
|
||
import type { | ||
Canvas, | ||
|
@@ -71,7 +71,7 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer | |
public channel: Channel, | ||
public store: StoryStore<TRenderer>, | ||
private renderToScreen: RenderToCanvas<TRenderer>, | ||
private callbacks: RenderContextCallbacks<TRenderer>, | ||
private callbacks: RenderContextCallbacks<TRenderer> & { showStoryDuringRender?: () => void }, | ||
public id: StoryId, | ||
public viewMode: StoryContext['viewMode'], | ||
public renderOptions: StoryRenderOptions = { autoplay: true, forceInitialArgs: false }, | ||
|
@@ -92,12 +92,19 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer | |
private async runPhase(signal: AbortSignal, phase: RenderPhase, phaseFn?: () => Promise<void>) { | ||
this.phase = phase; | ||
this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: this.phase, storyId: this.id }); | ||
if (phaseFn) await phaseFn(); | ||
if (phaseFn) { | ||
await phaseFn(); | ||
this.checkIfAborted(signal); | ||
} | ||
} | ||
|
||
private checkIfAborted(signal: AbortSignal): boolean { | ||
if (signal.aborted) { | ||
this.phase = 'aborted'; | ||
this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: this.phase, storyId: this.id }); | ||
return true; | ||
} | ||
return false; | ||
} | ||
|
||
async prepare() { | ||
|
@@ -196,22 +203,29 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer | |
step: (label, play) => runStep(label, play, context), | ||
context: null!, | ||
canvas: {} as Canvas, | ||
mount: null!, | ||
renderToCanvas: async () => { | ||
const teardown = await this.renderToScreen(renderContext, canvasElement); | ||
this.teardownRender = teardown || (() => {}); | ||
mounted = true; | ||
}, | ||
// The story provides (set in a renderer) a mount function that is a higher order function | ||
// (context) => (...args) => Canvas | ||
// | ||
// Before assigning it to the context, we resolve the context dependency, | ||
// so that a user can just call it as await mount(...args) in their play function. | ||
mount: async (...args) => { | ||
this.callbacks.showStoryDuringRender?.(); | ||
let mountReturn: Awaited<ReturnType<StoryContext['mount']>> = null!; | ||
await this.runPhase(abortSignal, 'rendering', async () => { | ||
const teardown = await this.renderToScreen(renderContext, canvasElement); | ||
this.teardownRender = teardown || (() => {}); | ||
mounted = true; | ||
mountReturn = await story.mount(context)(...args); | ||
}); | ||
|
||
if (isMountDestructured) { | ||
// put the phase back to playing if mount is used inside a play function | ||
await this.runPhase(abortSignal, 'playing', async () => {}); | ||
} | ||
// start playing phase if mount is used inside a play function | ||
if (isMountDestructured) await this.runPhase(abortSignal, 'playing'); | ||
return mountReturn; | ||
}, | ||
}; | ||
|
||
context.context = context; | ||
context.mount = this.story.mount(context); | ||
|
||
const renderContext: RenderContext<TRenderer> = { | ||
componentId, | ||
|
@@ -241,12 +255,10 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer | |
|
||
if (abortSignal.aborted) return; | ||
|
||
await this.runPhase(abortSignal, 'beforeEach', async () => { | ||
const cleanupCallbacks = await applyBeforeEach(context); | ||
this.store.addCleanupCallbacks(story, cleanupCallbacks); | ||
}); | ||
const cleanupCallbacks = await applyBeforeEach(context); | ||
this.store.addCleanupCallbacks(story, cleanupCallbacks); | ||
|
||
if (abortSignal.aborted) return; | ||
if (this.checkIfAborted(abortSignal)) return; | ||
|
||
if (!mounted && !isMountDestructured) { | ||
await context.mount(); | ||
|
@@ -272,16 +284,26 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer | |
context.mount = async () => { | ||
throw new MountMustBeDestructuredError({ playFunction: playFunction.toString() }); | ||
}; | ||
} | ||
await this.runPhase(abortSignal, 'playing', async () => { | ||
await this.runPhase(abortSignal, 'playing', async () => playFunction(context)); | ||
} else { | ||
// when mount is used the playing phase will start later, right after mount is called in the play function | ||
await playFunction(context); | ||
}); | ||
} | ||
|
||
if (!mounted) { | ||
throw new NoStoryMountedError(); | ||
} | ||
Comment on lines
+293
to
+295
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @yannbf this should solve your problem with a endless loaders, if you don't mount |
||
this.checkIfAborted(abortSignal); | ||
|
||
if (!ignoreUnhandledErrors && unhandledErrors.size > 0) { | ||
await this.runPhase(abortSignal, 'errored'); | ||
} else { | ||
await this.runPhase(abortSignal, 'played'); | ||
} | ||
} catch (error) { | ||
// Remove the loading screen, even if there was an error before rendering | ||
this.callbacks.showStoryDuringRender?.(); | ||
|
||
await this.runPhase(abortSignal, 'errored', async () => { | ||
this.channel.emit(PLAY_FUNCTION_THREW_EXCEPTION, serializeError(error)); | ||
}); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tmeasday I changed the assertion. As we prevent calling mount twice, if the user wants to mount in play.
It will always be called once. If not, we throw an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kasperpeulen Can we add a test for calling mount twice? Or did I miss it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We kind of
auto-mount
before the play function, except when you destructure mount.So this test ensures that
mount
is only called once in that scenario and not twice.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. What if I have a play function like this though?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, let's add a test that we throw in that scenario! But I agree this change is an improvement.