diff --git a/lib/api/src/modules/url.ts b/lib/api/src/modules/url.ts index 5aadd89f7e6..be743936aa7 100644 --- a/lib/api/src/modules/url.ts +++ b/lib/api/src/modules/url.ts @@ -152,10 +152,7 @@ export const init: ModuleFn = ({ store, navigate, state, provider, fullAPI, ...r const updateArgsParam = (args?: Story['args']) => { const currentStory = fullAPI.getCurrentStoryData(); const initialArgs = (isStory(currentStory) && currentStory.initialArgs) || {}; - const customizedArgs = Object.entries(args || {}).reduce((acc, [key, value]) => { - return deepEqual(value, initialArgs[key]) ? acc : Object.assign(acc, { [key]: value }); - }, {} as Story['args']); - const argsString = buildArgsParam(customizedArgs); + const argsString = buildArgsParam(initialArgs, args); const argsParam = argsString.length ? `&args=${argsString}` : ''; queryNavigate(`${fullAPI.getUrlState().path}${argsParam}`, { replace: true }); api.setQueryParams({ args: argsString }); diff --git a/lib/core/src/client/preview/parseArgsParam.ts b/lib/core/src/client/preview/parseArgsParam.ts index f05e2037bf7..09ab841be01 100644 --- a/lib/core/src/client/preview/parseArgsParam.ts +++ b/lib/core/src/client/preview/parseArgsParam.ts @@ -15,13 +15,14 @@ const validateArgs = (key = '', value: any = ''): boolean => { const QS_OPTIONS = { delimiter: ';', // we're parsing a single query param allowDots: true, // objects are encoded using dot notation + allowSparse: true, // arrays will be merged on top of their initial value }; export const parseArgsParam = (argsString: string): Args => { const parts = argsString.split(';').map((part) => part.replace('=', '~').replace(':', '=')); return Object.entries(qs.parse(parts.join(';'), QS_OPTIONS)).reduce((acc, [key, value]) => { if (validateArgs(key, value)) return Object.assign(acc, { [key]: value }); once.warn( - 'Cannot safely apply some args from the URL. See https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url' + 'Omitted potentially unsafe URL args.\n\nMore info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url' ); return acc; }, {} as Args); diff --git a/lib/router/package.json b/lib/router/package.json index d70e80be6f9..00973bb5afe 100644 --- a/lib/router/package.json +++ b/lib/router/package.json @@ -40,6 +40,7 @@ "@storybook/client-logger": "6.2.0-alpha.25", "@types/reach__router": "^1.3.7", "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", "global": "^4.4.0", "memoizerific": "^1.11.3", "qs": "^6.9.5" diff --git a/lib/router/src/utils.test.ts b/lib/router/src/utils.test.ts index 56eeb59fbfe..2cee08acb42 100644 --- a/lib/router/src/utils.test.ts +++ b/lib/router/src/utils.test.ts @@ -78,52 +78,77 @@ describe('parsePath', () => { describe('buildArgsParam', () => { it('builds a simple key-value pair', () => { - const param = buildArgsParam({ key: 'val' }); + const param = buildArgsParam({}, { key: 'val' }); expect(param).toEqual('key:val'); }); it('builds multiple values', () => { - const param = buildArgsParam({ one: '1', two: '2', three: '3' }); + const param = buildArgsParam({}, { one: '1', two: '2', three: '3' }); expect(param).toEqual('one:1;two:2;three:3'); }); it('builds booleans', () => { - const param = buildArgsParam({ yes: true, no: false }); + const param = buildArgsParam({}, { yes: true, no: false }); expect(param).toEqual('yes:true;no:false'); }); it('builds arrays', () => { - const param = buildArgsParam({ arr: ['1', '2', '3'] }); - expect(param).toEqual('arr[]:1;arr[]:2;arr[]:3'); + const param = buildArgsParam({}, { arr: ['1', '2', '3'] }); + expect(param).toEqual('arr[0]:1;arr[1]:2;arr[2]:3'); }); it('builds simple objects', () => { - const param = buildArgsParam({ obj: { one: '1', two: '2' } }); + const param = buildArgsParam({}, { obj: { one: '1', two: '2' } }); expect(param).toEqual('obj.one:1;obj.two:2'); }); it('builds nested objects', () => { - const param = buildArgsParam({ obj: { foo: { one: '1', two: '2' }, bar: { one: '1' } } }); + const param = buildArgsParam({}, { obj: { foo: { one: '1', two: '2' }, bar: { one: '1' } } }); expect(param).toEqual('obj.foo.one:1;obj.foo.two:2;obj.bar.one:1'); }); it('builds arrays in objects', () => { - const param = buildArgsParam({ obj: { foo: ['1', '2'] } }); - expect(param).toEqual('obj.foo[]:1;obj.foo[]:2'); + const param = buildArgsParam({}, { obj: { foo: ['1', '2'] } }); + expect(param).toEqual('obj.foo[0]:1;obj.foo[1]:2'); }); it('builds single object in array', () => { - const param = buildArgsParam({ arr: [{ one: '1', two: '2' }] }); - expect(param).toEqual('arr[].one:1;arr[].two:2'); + const param = buildArgsParam({}, { arr: [{ one: '1', two: '2' }] }); + expect(param).toEqual('arr[0].one:1;arr[0].two:2'); }); it('builds multiple objects in array', () => { - const param = buildArgsParam({ arr: [{ one: '1' }, { two: '2' }] }); - expect(param).toEqual('arr[].one:1;arr[].two:2'); + const param = buildArgsParam({}, { arr: [{ one: '1' }, { two: '2' }] }); + expect(param).toEqual('arr[0].one:1;arr[1].two:2'); }); it('builds nested object in array', () => { - const param = buildArgsParam({ arr: [{ foo: { bar: 'val' } }] }); - expect(param).toEqual('arr[].foo.bar:val'); + const param = buildArgsParam({}, { arr: [{ foo: { bar: 'val' } }] }); + expect(param).toEqual('arr[0].foo.bar:val'); + }); + + describe('with initial state', () => { + it('omits unchanged values', () => { + const param = buildArgsParam({ one: 1 }, { one: 1, two: 2 }); + expect(param).toEqual('two:2'); + }); + + it('omits unchanged object properties', () => { + const param = buildArgsParam({ obj: { one: 1 } }, { obj: { one: 1, two: 2 } }); + expect(param).toEqual('obj.two:2'); + }); + + it('omits unchanged array values (yielding sparse arrays)', () => { + const param = buildArgsParam({ arr: [1, 2, 3] }, { arr: [1, 3, 4] }); + expect(param).toEqual('arr[1]:3;arr[2]:4'); + }); + + it('omits nested unchanged object properties and array values', () => { + const param = buildArgsParam( + { obj: { nested: [{ one: 1 }, { two: 2 }] } }, + { obj: { nested: [{ one: 1 }, { two: 2, three: 3 }] } } + ); + expect(param).toEqual('obj.nested[1].three:3'); + }); }); }); diff --git a/lib/router/src/utils.ts b/lib/router/src/utils.ts index 1650af1177b..65bd0dcb019 100644 --- a/lib/router/src/utils.ts +++ b/lib/router/src/utils.ts @@ -1,3 +1,4 @@ +import deepEqual from 'fast-deep-equal'; import qs from 'qs'; import memoize from 'memoizerific'; import { once } from '@storybook/client-logger'; @@ -36,6 +37,22 @@ interface Args { [key: string]: any; } +const deepDiff = (value: any, update: any): any => { + if (deepEqual(value, update)) return undefined; + if (typeof value !== typeof update) return update; + if (Array.isArray(value)) { + if (!Array.isArray(update)) return update; + return update.map((upd, index) => deepDiff(value[index], upd)); + } + if (typeof update === 'object') { + return Object.keys(update).reduce((acc, key) => { + const diff = deepDiff(value[key], update[key]); + return diff === undefined ? acc : Object.assign(acc, { [key]: diff }); + }, {}); + } + return update; +}; + // Keep this in sync with validateArgs in @storybook/core const VALIDATION_REGEXP = /^[a-zA-Z0-9 _-]*$/; const validateArgs = (key = '', value: any = ''): boolean => { @@ -50,19 +67,25 @@ const QS_OPTIONS = { encode: false, // we handle URL encoding ourselves delimiter: ';', // we don't actually create multiple query params allowDots: true, // encode objects using dot notation: obj.key=val - arrayFormat: 'brackets', // encode arrays using brackets without indices: arr[]=one&arr[]=two format: 'RFC1738', // encode spaces using the + sign }; -export const buildArgsParam = (args: Args) => { - const object = Object.entries(args).reduce((acc, [key, value]) => { +export const buildArgsParam = (initialArgs: Args, args: Args): string => { + const update = deepDiff(initialArgs, args); + if (!update) return ''; + + const object = Object.entries(update).reduce((acc, [key, value]) => { if (validateArgs(key, value)) return Object.assign(acc, { [key]: value }); once.warn( - 'Some args cannot be safely serialized to the URL. See https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url' + 'Omitted potentially unsafe URL args.\n\nMore info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url' ); return acc; }, {} as Args); - const parts = qs.stringify(object, QS_OPTIONS).split(';'); - return parts.map((part: string) => part.replace('=', ':')).join(';'); + + return qs + .stringify(object, QS_OPTIONS) + .split(';') + .map((part: string) => part.replace('=', ':')) + .join(';'); }; interface Query {