Skip to content

Commit

Permalink
fix(useurlstate): ignore/preserve sp not defined in stateShape
Browse files Browse the repository at this point in the history
  • Loading branch information
asmyshlyaev177 committed Aug 1, 2024
1 parent d97d03b commit 502c4e3
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 42 deletions.
7 changes: 4 additions & 3 deletions packages/urlstate/next/useUrlState/useUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ export function useUrlState<T extends JSONCompatible>(
);

const sp = useSearchParams();

React.useEffect(() => {
updateState(
parseSsrQs(Object.fromEntries([...sp.entries()]), defaultState),
const shapeKeys = Object.keys(defaultState);
const _sp = Object.fromEntries(
[...sp.entries()].filter(([key]) => shapeKeys.includes(key)),
);
updateState(parseSsrQs(_sp, defaultState));
}, [sp]);

return {
Expand Down
80 changes: 78 additions & 2 deletions packages/urlstate/useUrlStateBase/useUrlStateBase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { act, fireEvent, renderHook } from '@testing-library/react';

import { useUrlStateBase } from '.';
import * as sharedState from '../useSharedState';
import * as urlEncode from '../useUrlEncode';
import * as utils from '../utils';

// TODO: playwright tests with 2 components on 2 pages

type State = {
str: string;
num: number;
Expand Down Expand Up @@ -105,6 +104,50 @@ describe('useUrlStateBase', () => {
const fnArg = sharedStateSpy.mock.calls.slice(-1)[0][1];
expect(fnArg?.()).toStrictEqual(stateShape);
});

describe('with existing queryParams', () => {
describe('state contains only fields from stateShape', () => {
it('ssr', () => {
jest.spyOn(utils, 'isSSR').mockReturnValue(true);
const sharedStateSpy = jest.spyOn(sharedState, 'useSharedState');
const { result } = renderHook(() =>
useUrlStateBase(stateShape, router, { key: 'value123' }),
);

expect(result.current.state).toStrictEqual(stateShape);

expect(sharedStateSpy).toHaveBeenCalledTimes(1);
expect(sharedStateSpy).toHaveBeenNthCalledWith(
1,
stateShape,
expect.any(Function),
);
});

it('client', () => {
jest.spyOn(utils, 'isSSR').mockReturnValue(false);
const search = '?key=value123';
const originalLocation = window.location;
jest.spyOn(window, 'location', 'get').mockImplementation(() => ({
...originalLocation,
search,
}));
const sharedStateSpy = jest.spyOn(sharedState, 'useSharedState');
const { result } = renderHook(() =>
useUrlStateBase(stateShape, router),
);

expect(result.current.state).toStrictEqual(stateShape);

expect(sharedStateSpy).toHaveBeenCalledTimes(1);
expect(sharedStateSpy).toHaveBeenNthCalledWith(
1,
stateShape,
expect.any(Function),
);
});
});
});
});

describe('return state', () => {
Expand Down Expand Up @@ -232,6 +275,39 @@ describe('useUrlStateBase', () => {
someOpt: 123,
});
});

describe('with existing queryParams', () => {
it('should only update fields from stateShape', () => {
jest.spyOn(utils, 'isSSR').mockReturnValue(false);
const sp = 'key=value123';
const search = `?${sp}`;
const originalLocation = window.location;
jest.spyOn(window, 'location', 'get').mockImplementation(() => ({
...originalLocation,
search,
}));
const stringify = jest.fn().mockReturnValue('');
jest.spyOn(urlEncode, 'useUrlEncode').mockImplementation(
jest.fn().mockReturnValue({
parse: () => stateShape,
stringify,
}),
);
const { result } = renderHook(() =>
useUrlStateBase(stateShape, router),
);

const newState = { ...stateShape, num: 30 };
act(() => {
result.current.updateUrl(newState);
});

expect(stringify).toHaveBeenCalledTimes(1);
const call = stringify.mock.calls[0];
expect(call[0]).toStrictEqual(newState);
expect(call[1].toString()).toStrictEqual(sp);
});
});
});

describe('updateState', () => {
Expand Down
48 changes: 42 additions & 6 deletions packages/urlstate/useUrlStateBase/useUrlStateBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,13 @@ export function useUrlStateBase<T extends JSONCompatible>(
searchParams?: object,
) {
const { parse, stringify } = useUrlEncode(defaultState);
// TODO: pass this block from useUrlState ?
const { state, getState, setState } = useSharedState(defaultState, () =>
isSSR()
? parseSsrQs(searchParams, defaultState)
: parse(window.location.search),
? parseSsrQs(filterSsrSP(defaultState, searchParams), defaultState)
: parse(filterClientSP(defaultState)),
);

// TODO: href ?

useInsertionEffect(() => {
// for history navigation
const popCb = () => {
Expand All @@ -62,15 +61,16 @@ export function useUrlStateBase<T extends JSONCompatible>(
) => {
const currUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const isFunc = typeof value === 'function';
const otherParams = getOtherParams(defaultState);

let newVal: T | DeepReadonly<T>;
let qStr: string;
if (isFunc) {
newVal = value(getState());
qStr = stringify(newVal);
qStr = stringify(newVal, otherParams);
} else {
newVal = (value ?? getState()) as T;
qStr = stringify(newVal);
qStr = stringify(newVal, otherParams);
}
setState(newVal);

Expand All @@ -94,6 +94,42 @@ export function useUrlStateBase<T extends JSONCompatible>(
};
}

// need to use only common fields between shape and params, ignore undeclared SP
function filterClientSP<T extends object>(shape: T) {
const params = new URLSearchParams(window.location.search);
const shapeKeys = Object.keys(shape);

const shapeParams = new URLSearchParams();
[...params.entries()]
.filter(([key]) => shapeKeys.includes(key))
.forEach(([key, value]) => shapeParams.set(key, value));

return shapeParams.toString();
}

function filterSsrSP<T extends object>(shape: T, searchParams?: object) {
const shapeKeys = Object.keys(shape);

const result = Object.fromEntries(
Object.entries(searchParams || {}).filter(([key]) =>
shapeKeys.includes(key),
),
);
return result as T;
}

function getOtherParams<T extends object>(shape: T) {
const shapeKeys = Object.keys(shape);
const search = window.location.search;
const allParams = new URLSearchParams(search);
const params = new URLSearchParams();

allParams.forEach(
(value, key) => !shapeKeys.includes(key) && params.set(key, value),
);
return params;
}

const popstateEv = 'popstate';

// eslint-disable-next-line @typescript-eslint/ban-types
Expand Down
93 changes: 62 additions & 31 deletions tests/useUrlState/updateUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { expect, test } from '@playwright/test';

import { toHaveUrl } from '../testUtils';

const urls = ['/test-ssr', '/test-use-client', '/test-ssr-sp'];

test('sync', async ({ page, baseURL }) => {
test('sync', async ({ page }) => {
for (const url of urls) {
await page.goto(url);
await page.waitForSelector('button[name="Reload page"]');
Expand Down Expand Up @@ -30,24 +32,21 @@ test('sync', async ({ page, baseURL }) => {
await page.waitForTimeout(500);
}

await expect(page).toHaveURL(`${baseURL}${url}`, {
timeout: 1000,
});
await toHaveUrl(page, url, true);

await expect(page.getByTestId('parsed')).toHaveText(expectedText);

// sync url
await page.getByTestId('sync-empty').click();
await page.waitForFunction(() => window.location.href.includes('?name='));

const expectedUrl = `?name=%E2%97%96My%2520Name&tags=%5B%7B%27id%27%3A%27%E2%97%961%27%2C%27value%27%3A%7B%27text%27%3A%27%E2%97%96React.js%27%2C%27time%27%3A%27%E2%97%962024-07-17T04%253A53%253A17.000Z%27%7D%7D%5D`;
await expect(page).toHaveURL(`${baseURL}${url}${expectedUrl}`, {
timeout: 1000,
});
await toHaveUrl(page, `${url}${expectedUrl}`, true);

await expect(page.getByTestId('parsed')).toHaveText(expectedText);
}
});

test('reset', async ({ page, baseURL }) => {
test('reset', async ({ page }) => {
for (const url of urls) {
await page.goto(url);
await page.waitForSelector('button[name="Reload page"]');
Expand Down Expand Up @@ -76,9 +75,8 @@ test('reset', async ({ page, baseURL }) => {
// sync url
await page.getByTestId('sync-default').click();

await expect(page).toHaveURL(`${baseURL}${url}`, {
timeout: 1000,
});
await toHaveUrl(page, `${url}`);

await expect(page.getByTestId('parsed')).toHaveText(`{
"name": "",
"agree to terms": false,
Expand All @@ -87,16 +85,19 @@ test('reset', async ({ page, baseURL }) => {
}
});

test('update', async ({ page, baseURL }) => {
for (const url of urls) {
await page.goto(url);
await page.waitForSelector('button[name="Reload page"]');
test.describe('update', () => {
test('should work', async ({ page }) => {
for (const url of urls) {
await page.goto(url);
await page.waitForSelector('button[name="Reload page"]');

await page.getByLabel('name').focus();
await page.getByLabel('name').pressSequentially('My Name', { delay: 150 });
await page.getByText('React.js').click();
await page.getByLabel('name').focus();
await page
.getByLabel('name')
.pressSequentially('My Name', { delay: 150 });
await page.getByText('React.js').click();

const expectedText = `{
const expectedText = `{
"name": "My Name",
"agree to terms": false,
"tags": [
Expand All @@ -110,18 +111,17 @@ test('update', async ({ page, baseURL }) => {
]
}`;

// syncing state but not url
await expect(page.getByTestId('parsed')).toHaveText(expectedText);
// syncing state but not url
await expect(page.getByTestId('parsed')).toHaveText(expectedText);

// update url
await page.getByTestId('sync-object').click();
// update url
await page.getByTestId('sync-object').click();

const expectedUrl =
'?name=%E2%97%96My%2520Name&age=%E2%88%9355&tags=%5B%7B%27id%27%3A%27%E2%97%961%27%2C%27value%27%3A%7B%27text%27%3A%27%E2%97%96React.js%27%2C%27time%27%3A%27%E2%97%962024-07-17T04%253A53%253A17.000Z%27%7D%7D%5D';
await expect(page).toHaveURL(`${baseURL}${url}${expectedUrl}`, {
timeout: 1000,
});
await expect(page.getByTestId('parsed')).toHaveText(`{
const expectedUrl =
'?name=%E2%97%96My%2520Name&age=%E2%88%9355&tags=%5B%7B%27id%27%3A%27%E2%97%961%27%2C%27value%27%3A%7B%27text%27%3A%27%E2%97%96React.js%27%2C%27time%27%3A%27%E2%97%962024-07-17T04%253A53%253A17.000Z%27%7D%7D%5D';
await toHaveUrl(page, `${url}${expectedUrl}`);

await expect(page.getByTestId('parsed')).toHaveText(`{
"name": "My Name",
"age": 55,
"agree to terms": false,
Expand All @@ -135,5 +135,36 @@ test('update', async ({ page, baseURL }) => {
}
]
}`);
}
}
});

test('should preserve existing query params', async ({ page }) => {
for (const url of urls) {
const sp = 'key1=someValue';
await page.goto(`${url}?${sp}`);
await page.waitForSelector('button[name="Reload page"]');

await page.getByLabel('name').focus();
await page
.getByLabel('name')
.pressSequentially('My Name', { delay: 150 });

const expectedText = `{
"name": "My Name",
"agree to terms": false,
"tags": []
}`;

// syncing state but not url
await expect(page.getByTestId('parsed')).toHaveText(expectedText);

// update url
await page.getByTestId('sync-empty').click();

const expectedUrl = `?${sp}&name=%E2%97%96My%2520Name`;
await toHaveUrl(page, `${url}${expectedUrl}`);

await expect(page.getByTestId('parsed')).toHaveText(expectedText);
}
});
});

0 comments on commit 502c4e3

Please sign in to comment.