Skip to content

Commit

Permalink
Merge pull request #29044 from storybookjs/valentin/propagate-error-i…
Browse files Browse the repository at this point in the history
…n-testing

Portable Stories: Improve Handling of React Updates and Errors
  • Loading branch information
kasperpeulen authored Sep 11, 2024
2 parents 87bf34c + 46aa6e0 commit 603841c
Show file tree
Hide file tree
Showing 22 changed files with 466 additions and 208 deletions.
3 changes: 3 additions & 0 deletions code/addons/interactions/src/preview.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { PlayFunction, StepLabel, StoryContext } from 'storybook/internal/types';

import { instrument } from '@storybook/instrumenter';
// This makes sure that storybook test loaders are always loaded when addon-interactions is used
// For 9.0 we want to merge storybook/test and addon-interactions into one addon.
import '@storybook/test';

export const { step: runStep } = instrument(
{
Expand Down
12 changes: 11 additions & 1 deletion code/core/src/preview-api/modules/store/csf/portable-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,17 @@ export function setProjectAnnotations<TRenderer extends Renderer = Renderer>(
const annotations = Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations];
globalThis.globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));

return globalThis.globalProjectAnnotations;
/*
We must return the composition of default and global annotations here
To ensure that the user has the full project annotations, eg. when running
const projectAnnotations = setProjectAnnotations(...);
beforeAll(projectAnnotations.beforeAll)
*/
return composeConfigs([
globalThis.defaultProjectAnnotations ?? {},
globalThis.globalProjectAnnotations ?? {},
]);
}

const cleanups: CleanupCallback[] = [];
Expand Down
16 changes: 8 additions & 8 deletions code/core/template/stories/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ export const parameters = {

export const loaders = [async () => ({ projectValue: 2 })];

export const decorators = [
(storyFn: PartialStoryFn, context: StoryContext) => {
if (context.parameters.useProjectDecorator) {
return storyFn({ args: { ...context.args, text: `project ${context.args.text}` } });
}
return storyFn();
},
];
const testProjectDecorator = (storyFn: PartialStoryFn, context: StoryContext) => {
if (context.parameters.useProjectDecorator) {
return storyFn({ args: { ...context.args, text: `project ${context.args.text}` } });
}
return storyFn();
};

export const decorators = [testProjectDecorator];

export const initialGlobals = {
foo: 'fooValue',
Expand Down
2 changes: 1 addition & 1 deletion code/frameworks/experimental-nextjs-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
"@storybook/react": "workspace:*",
"@storybook/test": "workspace:*",
"styled-jsx": "5.1.6",
"vite-plugin-storybook-nextjs": "^1.0.10"
"vite-plugin-storybook-nextjs": "^1.0.11"
},
"devDependencies": {
"@types/node": "^18.0.0",
Expand Down
24 changes: 22 additions & 2 deletions code/frameworks/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
import type { Configuration as WebpackConfig } from 'webpack';
import { DefinePlugin } from 'webpack';

import { addScopedAlias, resolveNextConfig } from '../utils';
import { addScopedAlias, resolveNextConfig, setAlias } from '../utils';

const tryResolve = (path: string) => {
try {
Expand All @@ -22,12 +22,32 @@ export const configureConfig = async ({
const nextConfig = await resolveNextConfig({ nextConfigPath });

addScopedAlias(baseConfig, 'next/config');

// @ts-expect-error We know that alias is an object
if (baseConfig.resolve?.alias?.['react-dom']) {
// Removing the alias to react-dom to avoid conflicts with the alias we are setting
// because the react-dom alias is an exact match and we need to alias separate parts of react-dom
// in different places
// @ts-expect-error We know that alias is an object
delete baseConfig.resolve.alias?.['react-dom'];
}

if (tryResolve('next/dist/compiled/react')) {
addScopedAlias(baseConfig, 'react', 'next/dist/compiled/react');
}
if (tryResolve('next/dist/compiled/react-dom/cjs/react-dom-test-utils.production.js')) {
setAlias(
baseConfig,
'react-dom/test-utils',
'next/dist/compiled/react-dom/cjs/react-dom-test-utils.production.js'
);
}
if (tryResolve('next/dist/compiled/react-dom')) {
addScopedAlias(baseConfig, 'react-dom', 'next/dist/compiled/react-dom');
setAlias(baseConfig, 'react-dom$', 'next/dist/compiled/react-dom');
setAlias(baseConfig, 'react-dom/client', 'next/dist/compiled/react-dom/client');
setAlias(baseConfig, 'react-dom/server', 'next/dist/compiled/react-dom/server');
}

setupRuntimeConfig(baseConfig, nextConfig);

return nextConfig;
Expand Down
20 changes: 12 additions & 8 deletions code/frameworks/nextjs/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,27 @@ export const resolveNextConfig = async ({
return loadConfig(PHASE_DEVELOPMENT_SERVER, dir, undefined);
};

// This is to help the addon in development
// Without it, webpack resolves packages in its node_modules instead of the example's node_modules
export const addScopedAlias = (baseConfig: WebpackConfig, name: string, alias?: string): void => {
export function setAlias(baseConfig: WebpackConfig, name: string, alias: string) {
baseConfig.resolve ??= {};
baseConfig.resolve.alias ??= {};
const aliasConfig = baseConfig.resolve.alias;

const scopedAlias = scopedResolve(`${alias ?? name}`);

if (Array.isArray(aliasConfig)) {
aliasConfig.push({
name,
alias: scopedAlias,
alias,
});
} else {
aliasConfig[name] = scopedAlias;
aliasConfig[name] = alias;
}
}

// This is to help the addon in development
// Without it, webpack resolves packages in its node_modules instead of the example's node_modules
export const addScopedAlias = (baseConfig: WebpackConfig, name: string, alias?: string): void => {
const scopedAlias = scopedResolve(`${alias ?? name}`);

setAlias(baseConfig, name, scopedAlias);
};

/**
Expand All @@ -64,7 +68,7 @@ export const scopedResolve = (id: string): string => {
let scopedModulePath;

try {
// TODO: Remove in next major release (SB 8.0) and use the statement in the catch block per default instead
// TODO: Remove in next major release (SB 9.0) and use the statement in the catch block per default instead
scopedModulePath = require.resolve(id, { paths: [resolve()] });
} catch (e) {
scopedModulePath = require.resolve(id);
Expand Down
222 changes: 111 additions & 111 deletions code/frameworks/sveltekit/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,125 +15,125 @@ const normalizeHrefConfig = (hrefConfig: HrefConfig): NormalizedHrefConfig => {
return hrefConfig;
};

export const decorators: Decorator[] = [
(Story, ctx) => {
const svelteKitParameters: SvelteKitParameters = ctx.parameters?.sveltekit_experimental ?? {};
setPage(svelteKitParameters?.stores?.page);
setNavigating(svelteKitParameters?.stores?.navigating);
setUpdated(svelteKitParameters?.stores?.updated);
setAfterNavigateArgument(svelteKitParameters?.navigation?.afterNavigate);
const svelteKitMocksDecorator: Decorator = (Story, ctx) => {
const svelteKitParameters: SvelteKitParameters = ctx.parameters?.sveltekit_experimental ?? {};
setPage(svelteKitParameters?.stores?.page);
setNavigating(svelteKitParameters?.stores?.navigating);
setUpdated(svelteKitParameters?.stores?.updated);
setAfterNavigateArgument(svelteKitParameters?.navigation?.afterNavigate);

onMount(() => {
const globalClickListener = (e: MouseEvent) => {
// we add a global click event listener and we check if there's a link in the composedPath
const path = e.composedPath();
const element = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
if (element && element instanceof HTMLAnchorElement) {
// if the element is an a-tag we get the href of the element
// and compare it to the hrefs-parameter set by the user
const to = element.getAttribute('href');
if (!to) {
return;
}
e.preventDefault();
const defaultActionCallback = () => action('navigate')(to, e);
if (!svelteKitParameters.hrefs) {
defaultActionCallback();
return;
}

let callDefaultCallback = true;
// we loop over every href set by the user and check if the href matches
// if it does we call the callback provided by the user and disable the default callback
Object.entries(svelteKitParameters.hrefs).forEach(([href, hrefConfig]) => {
const { callback, asRegex } = normalizeHrefConfig(hrefConfig);
const isMatch = asRegex ? new RegExp(href).test(to) : to === href;
if (isMatch) {
callDefaultCallback = false;
callback?.(to, e);
}
});
if (callDefaultCallback) {
defaultActionCallback();
}
onMount(() => {
const globalClickListener = (e: MouseEvent) => {
// we add a global click event listener and we check if there's a link in the composedPath
const path = e.composedPath();
const element = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
if (element && element instanceof HTMLAnchorElement) {
// if the element is an a-tag we get the href of the element
// and compare it to the hrefs-parameter set by the user
const to = element.getAttribute('href');
if (!to) {
return;
}
e.preventDefault();
const defaultActionCallback = () => action('navigate')(to, e);
if (!svelteKitParameters.hrefs) {
defaultActionCallback();
return;
}
};

/**
* Function that create and add listeners for the event that are emitted by the mocked
* functions. The event name is based on the function name
*
* Eg. storybook:goto, storybook:invalidateAll
*
* @param baseModule The base module where the function lives (navigation|forms)
* @param functions The list of functions in that module that emit events
* @param {boolean} [defaultToAction] The list of functions in that module that emit events
* @returns A function to remove all the listener added
*/
function createListeners(
baseModule: keyof SvelteKitParameters,
functions: string[],
defaultToAction?: boolean
) {
// the array of every added listener, we can use this in the return function
// to clean them
const toRemove: Array<{
eventType: string;
listener: (event: { detail: any[] }) => void;
}> = [];
functions.forEach((func) => {
// we loop over every function and check if the user actually passed
// a function in sveltekit_experimental[baseModule][func] eg. sveltekit_experimental.navigation.goto
const hasFunction =
(svelteKitParameters as any)[baseModule]?.[func] &&
(svelteKitParameters as any)[baseModule][func] instanceof Function;
// if we default to an action we still add the listener (this will be the case for goto, invalidate, invalidateAll)
if (hasFunction || defaultToAction) {
// we create the listener that will just get the detail array from the custom element
// and call the user provided function spreading this args in...this will basically call
// the function that the user provide with the same arguments the function is invoked to

// eg. if it calls goto("/my-route") inside the component the function sveltekit_experimental.navigation.goto
// it provided to storybook will be called with "/my-route"
const listener = ({ detail = [] as any[] }) => {
const args = Array.isArray(detail) ? detail : [];
// if it has a function in the parameters we call that function
// otherwise we invoke the action
const fnToCall = hasFunction
? (svelteKitParameters as any)[baseModule][func]
: action(func);
fnToCall(...args);
};
const eventType = `storybook:${func}`;
toRemove.push({ eventType, listener });
// add the listener to window
(window.addEventListener as any)(eventType, listener);
let callDefaultCallback = true;
// we loop over every href set by the user and check if the href matches
// if it does we call the callback provided by the user and disable the default callback
Object.entries(svelteKitParameters.hrefs).forEach(([href, hrefConfig]) => {
const { callback, asRegex } = normalizeHrefConfig(hrefConfig);
const isMatch = asRegex ? new RegExp(href).test(to) : to === href;
if (isMatch) {
callDefaultCallback = false;
callback?.(to, e);
}
});
return () => {
// loop over every listener added and remove them
toRemove.forEach(({ eventType, listener }) => {
// @ts-expect-error apparently you can't remove a custom listener to the window with TS
window.removeEventListener(eventType, listener);
});
};
if (callDefaultCallback) {
defaultActionCallback();
}
}
};

const removeNavigationListeners = createListeners(
'navigation',
['goto', 'invalidate', 'invalidateAll', 'pushState', 'replaceState'],
true
);
const removeFormsListeners = createListeners('forms', ['enhance']);
window.addEventListener('click', globalClickListener);
/**
* Function that create and add listeners for the event that are emitted by the mocked
* functions. The event name is based on the function name
*
* Eg. storybook:goto, storybook:invalidateAll
*
* @param baseModule The base module where the function lives (navigation|forms)
* @param functions The list of functions in that module that emit events
* @param {boolean} [defaultToAction] The list of functions in that module that emit events
* @returns A function to remove all the listener added
*/
function createListeners(
baseModule: keyof SvelteKitParameters,
functions: string[],
defaultToAction?: boolean
) {
// the array of every added listener, we can use this in the return function
// to clean them
const toRemove: Array<{
eventType: string;
listener: (event: { detail: any[] }) => void;
}> = [];
functions.forEach((func) => {
// we loop over every function and check if the user actually passed
// a function in sveltekit_experimental[baseModule][func] eg. sveltekit_experimental.navigation.goto
const hasFunction =
(svelteKitParameters as any)[baseModule]?.[func] &&
(svelteKitParameters as any)[baseModule][func] instanceof Function;
// if we default to an action we still add the listener (this will be the case for goto, invalidate, invalidateAll)
if (hasFunction || defaultToAction) {
// we create the listener that will just get the detail array from the custom element
// and call the user provided function spreading this args in...this will basically call
// the function that the user provide with the same arguments the function is invoked to

// eg. if it calls goto("/my-route") inside the component the function sveltekit_experimental.navigation.goto
// it provided to storybook will be called with "/my-route"
const listener = ({ detail = [] as any[] }) => {
const args = Array.isArray(detail) ? detail : [];
// if it has a function in the parameters we call that function
// otherwise we invoke the action
const fnToCall = hasFunction
? (svelteKitParameters as any)[baseModule][func]
: action(func);
fnToCall(...args);
};
const eventType = `storybook:${func}`;
toRemove.push({ eventType, listener });
// add the listener to window
(window.addEventListener as any)(eventType, listener);
}
});
return () => {
window.removeEventListener('click', globalClickListener);
removeNavigationListeners();
removeFormsListeners();
// loop over every listener added and remove them
toRemove.forEach(({ eventType, listener }) => {
// @ts-expect-error apparently you can't remove a custom listener to the window with TS
window.removeEventListener(eventType, listener);
});
};
});
}

const removeNavigationListeners = createListeners(
'navigation',
['goto', 'invalidate', 'invalidateAll', 'pushState', 'replaceState'],
true
);
const removeFormsListeners = createListeners('forms', ['enhance']);
window.addEventListener('click', globalClickListener);

return () => {
window.removeEventListener('click', globalClickListener);
removeNavigationListeners();
removeFormsListeners();
};
});

return Story();
};

return Story();
},
];
export const decorators: Decorator[] = [svelteKitMocksDecorator];
Loading

0 comments on commit 603841c

Please sign in to comment.