Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions code/frameworks/sveltekit/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const config: BuildEntries = {
},
],
},
extraOutputs: {
'./internal/MockProvider.svelte': './static/MockProvider.svelte',
},
};

export default config;
5 changes: 3 additions & 2 deletions code/frameworks/sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./internal/MockProvider.svelte": "./static/MockProvider.svelte",
"./internal/mocks/app/forms": "./dist/mocks/app/forms.js",
"./internal/mocks/app/navigation": "./dist/mocks/app/navigation.js",
"./internal/mocks/app/stores": "./dist/mocks/app/stores.js",
Expand All @@ -46,11 +47,11 @@
},
"files": [
"dist/**/*",
"static/**/*",
"template/**/*",
"README.md",
"*.js",
"*.d.ts",
"src/mocks/**/*"
"*.d.ts"
],
"scripts": {
"check": "jiti ../../../scripts/check/check-package.ts",
Expand Down
137 changes: 8 additions & 129 deletions code/frameworks/sveltekit/src/preview.ts
Original file line number Diff line number Diff line change
@@ -1,138 +1,17 @@
import type { Decorator } from '@storybook/svelte';
import MockProvider from '@storybook/sveltekit/internal/MockProvider.svelte';

import { action } from 'storybook/actions';
import { onMount } from 'svelte';

import { setAfterNavigateArgument } from './mocks/app/navigation';
import { setNavigating, setPage, setUpdated } from './mocks/app/stores';
import type { HrefConfig, NormalizedHrefConfig, SvelteKitParameters } from './types';

const normalizeHrefConfig = (hrefConfig: HrefConfig): NormalizedHrefConfig => {
if (typeof hrefConfig === 'function') {
return { callback: hrefConfig, asRegex: false };
}
return hrefConfig;
};
import type { SvelteKitParameters } from './types';

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();
}
}
};

/**
* 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 () => {
// 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 {
Component: MockProvider,
props: {
svelteKitParameters,
},
};
};

export const decorators: Decorator[] = [svelteKitMocksDecorator];
129 changes: 129 additions & 0 deletions code/frameworks/sveltekit/static/MockProvider.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script>
import { onMount } from 'svelte';
import { action } from 'storybook/actions';

import { setAfterNavigateArgument } from '@storybook/sveltekit/internal/mocks/app/navigation';
import { setNavigating, setPage, setUpdated } from '@storybook/sveltekit/internal/mocks/app/stores';


const{ svelteKitParameters = {}, children } = $props();

Comment thread
JReinhold marked this conversation as resolved.

// Set context during component initialization - this happens before any child components
setPage(svelteKitParameters?.stores?.page);
setNavigating(svelteKitParameters?.stores?.navigating);
setUpdated(svelteKitParameters?.stores?.updated);
setAfterNavigateArgument(svelteKitParameters?.navigation?.afterNavigate);

const normalizeHrefConfig = (hrefConfig) => {
if (typeof hrefConfig === 'function') {
return { callback: hrefConfig, asRegex: false };
}
return hrefConfig;
};
Comment on lines +18 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Normalize href config defensively

If a non‑object slips in, return a safe default.

Apply this diff:

-  const normalizeHrefConfig = (hrefConfig) => {
-    if (typeof hrefConfig === 'function') {
-      return { callback: hrefConfig, asRegex: false };
-    }
-    return hrefConfig;
-  };
+  const normalizeHrefConfig = (hrefConfig) => {
+    if (typeof hrefConfig === 'function') return { callback: hrefConfig, asRegex: false };
+    return hrefConfig && typeof hrefConfig === 'object'
+      ? hrefConfig
+      : { callback: undefined, asRegex: false };
+  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const normalizeHrefConfig = (hrefConfig) => {
if (typeof hrefConfig === 'function') {
return { callback: hrefConfig, asRegex: false };
}
return hrefConfig;
};
const normalizeHrefConfig = (hrefConfig) => {
if (typeof hrefConfig === 'function') return { callback: hrefConfig, asRegex: false };
return hrefConfig && typeof hrefConfig === 'object'
? hrefConfig
: { callback: undefined, asRegex: false };
};
🤖 Prompt for AI Agents
In code/frameworks/sveltekit/static/MockProvider.svelte around lines 18 to 23,
normalizeHrefConfig should defensively handle non-object inputs: keep the
existing branch for functions, but if hrefConfig is not a non-null object return
a safe default like { callback: (href) => href, asRegex: false }; if it is an
object ensure it has a callback (defaulting to identity) and an asRegex boolean
(defaulting to false) before returning it.


onMount(() => {
const globalClickListener = (e) => {
// 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);
}
});
Comment thread
JReinhold marked this conversation as resolved.
if (callDefaultCallback) {
defaultActionCallback();
}
}
};

Comment thread
JReinhold marked this conversation as resolved.
/**
* 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, functions, defaultToAction) {
// the array of every added listener, we can use this in the return function
// to clean them
const toRemove = [];
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[baseModule]?.[func] &&
svelteKitParameters[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 = [] }) => {
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[baseModule][func]
: action(func);
fnToCall(...args);
};
const eventType = `storybook:${func}`;
toRemove.push({ eventType, listener });
// add the listener to window
window.addEventListener(eventType, listener);
}
});
return () => {
// loop over every listener added and remove them
toRemove.forEach(({ eventType, listener }) => {
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();
};
});
</script>

{@render children()}
1 change: 0 additions & 1 deletion code/frameworks/sveltekit/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"paths": {
"storybook/internal/*": ["../../lib/cli/core/*"]
},
"rootDir": "./src"
Comment thread
JReinhold marked this conversation as resolved.
},
"extends": "../../tsconfig.json",
"include": ["src/**/*"]
Expand Down
55 changes: 47 additions & 8 deletions scripts/tasks/sandbox-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import { SupportedLanguage } from '../../code/core/src/cli/project_types';
import { JsPackageManagerFactory } from '../../code/core/src/common/js-package-manager';
import storybookPackages from '../../code/core/src/common/versions';
import type { ConfigFile } from '../../code/core/src/csf-tools';
import { formatConfig, writeConfig } from '../../code/core/src/csf-tools';
import {
readConfig as csfReadConfig,
formatConfig,
writeConfig,
} from '../../code/core/src/csf-tools';
import type { TemplateKey } from '../../code/lib/cli-storybook/src/sandbox-templates';
import type { PassedOptionValues, Task, TemplateDetails } from '../task';
import { executeCLIStep, steps } from '../utils/cli-step';
Expand Down Expand Up @@ -170,19 +174,24 @@ export const init: Task['run'] = async (
const cwd = sandboxDir;

let extra = {};
if (template.expected.renderer === '@storybook/html') {
extra = { type: 'html' };
} else if (template.expected.renderer === '@storybook/server') {
extra = { type: 'server' };
} else if (template.expected.framework === '@storybook/react-native-web-vite') {
extra = { type: 'react_native_web' };

switch (template.expected.renderer) {
case '@storybook/html':
extra = { type: 'html' };
break;
case '@storybook/server':
extra = { type: 'server' };
break;
case '@storybook/svelte':
await prepareSvelteSandbox(cwd);
break;
}

switch (template.expected.framework) {
case '@storybook/react-native-web-vite':
extra = { type: 'react_native_web' };
await prepareReactNativeWebSandbox(cwd);
break;
default:
}

await executeCLIStep(steps.init, {
Expand Down Expand Up @@ -878,6 +887,36 @@ async function prepareReactNativeWebSandbox(cwd: string) {
}
}

async function prepareSvelteSandbox(cwd: string) {
const svelteConfigJsPath = join(cwd, 'svelte.config.js');
const svelteConfigTsPath = join(cwd, 'svelte.config.ts');

// Check which config file exists
const configPath = (await pathExists(svelteConfigTsPath))
? svelteConfigTsPath
: (await pathExists(svelteConfigJsPath))
? svelteConfigJsPath
: null;

if (!configPath) {
throw new Error(
`No svelte.config.js or svelte.config.ts found in sandbox: ${cwd}, cannot modify config.`
);
}

const svelteConfig = await csfReadConfig(configPath);

// Enable async components
// see https://svelte.dev/docs/svelte/await-expressions
svelteConfig.setFieldValue(['compilerOptions', 'experimental', 'async'], true);

// Enable remote functions
// see https://svelte.dev/docs/kit/remote-functions
svelteConfig.setFieldValue(['kit', 'experimental', 'remoteFunctions'], true);

await writeConfig(svelteConfig);
}

async function prepareAngularSandbox(cwd: string, templateName: string) {
const angularJson = await readJson(join(cwd, 'angular.json'));

Expand Down
Loading