Skip to content

Commit 4f882cd

Browse files
committed
wip: functional draft
1 parent 034ac5f commit 4f882cd

File tree

7 files changed

+1236
-44
lines changed

7 files changed

+1236
-44
lines changed

.eslintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
},
2424
{
2525
"files": ["*.ts", "*.tsx"],
26-
"extends": ["plugin:@nx/typescript"],
26+
"extends": ["plugin:@nx/typescript", "plugin:@nx/react"],
2727
"rules": {}
2828
},
2929
{

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
"@types/react-dom": "^18.2.15",
2626
"@typescript-eslint/eslint-plugin": "^5.60.1",
2727
"@typescript-eslint/parser": "^5.60.1",
28+
"eslint-plugin-react": "7.33.2",
29+
"eslint-plugin-react-hooks": "4.6.0",
30+
"eslint-plugin-jsx-a11y": "6.8.0",
31+
"eslint-plugin-import": "2.29.0",
2832
"esbuild": "^0.19.2",
2933
"eslint": "~8.46.0",
3034
"eslint-config-prettier": "^9.0.0",
@@ -35,7 +39,8 @@
3539
"ts-jest": "^29.1.0",
3640
"ts-node": "10.9.1",
3741
"typescript": "5.2.2",
38-
"verdaccio": "^5.0.4"
42+
"verdaccio": "^5.0.4",
43+
"next": "^14.0.1"
3944
},
4045
"nx": {
4146
"includedScripts": []

packages/core/package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
{
2-
"name": "url-state",
2+
"name": "react-url-state",
33
"version": "0.0.1",
44
"dependencies": {},
55
"type": "commonjs",
6-
"main": "./index.cjs"
6+
"main": "./index.cjs",
7+
"peerDependencies": {
8+
"react": "^18.0.0",
9+
"zod": "^3.0.0",
10+
"next": "^14.0.1"
11+
},
12+
"peerDependenciesMeta": {
13+
"next": {
14+
"optional": true
15+
}
16+
}
717
}

packages/core/src/index.ts

Lines changed: 121 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import { useCallback, useEffect, useRef, useState } from 'react';
2-
import { z } from 'zod'
3-
4-
let isSubscribed = false;
2+
import { z } from 'zod';
3+
import { urlParamsToObject } from './utils';
54

65
type StateUpdateNotifier = (searchString: string) => void;
6+
7+
let isSubscribed = false;
78
const subscribers = new Map<StateUpdateNotifier, StateUpdateNotifier>();
89

9-
let currentStateString = ''
10+
let currentStateString = '';
1011

1112
function update() {
12-
if (document.location.search !== currentStateString) {
13-
currentStateString = document.location.search;
14-
subscribers.forEach((subscriber) => subscriber(currentStateString));
15-
}
13+
if (document.location.search !== currentStateString) {
14+
currentStateString = document.location.search;
15+
subscribers.forEach((subscriber) => subscriber(currentStateString));
16+
}
1617
}
1718

1819
function subscribeToHistory(fn: StateUpdateNotifier) {
@@ -22,57 +23,145 @@ function subscribeToHistory(fn: StateUpdateNotifier) {
2223
return;
2324
}
2425

25-
window.addEventListener("popstate", update);
26+
window.addEventListener('popstate', update);
2627
}
2728

2829
function unsubscribeFromHistory(fn: StateUpdateNotifier) {
2930
subscribers.delete(fn);
3031

3132
if (subscribers.size === 0) {
32-
window.removeEventListener("popstate", update);
33+
window.removeEventListener('popstate', update);
3334
isSubscribed = false;
3435
}
3536
}
3637

37-
const schema = z.object({
38-
name: z.string(),
39-
age: z.number(),
40-
});
38+
// const schema = z.object({
39+
// name: z.string(),
40+
// age: z.number(),
41+
// });
4142

4243
export type UrlStateOptions = {
4344
preserveUnknown: boolean;
44-
}
45+
};
4546

46-
export type UrlState<T extends z.ZodObject<any>> = {
47-
data: T['_type'];
47+
export type UrlState<T extends z.ZodObject<Record<string, z.ZodTypeAny>>> = {
48+
data: z.infer<T> | null;
4849
isError: boolean;
50+
error: z.ZodError | null;
51+
};
52+
53+
// export type UrlStateValue =
54+
// | string
55+
// | { toString: () => string }
56+
// | (string | { toString: () => string })[];
57+
58+
export type UrlStateMethods<
59+
T extends z.ZodObject<Record<string, z.ZodTypeAny>>,
60+
> = {
61+
reset: () => void;
62+
replace: (data: z.infer<T>) => void;
63+
setValue: <K extends keyof z.infer<T>>(key: K, value: z.infer<T>[K]) => void;
64+
};
65+
66+
function usePush(): (href: string) => void {
67+
return useCallback((href: string) => {
68+
window.history.pushState({}, '', href);
69+
}, []);
4970
}
5071

51-
export function useUrlState<T extends z.ZodObject<any>>(schema: T, options?: UrlStateOptions): UrlState<T> {
72+
export function useUrlState<
73+
T extends z.ZodObject<Record<string, z.ZodTypeAny>>,
74+
>(schema: T, options?: UrlStateOptions): UrlState<T> & UrlStateMethods<T> {
5275
options = {
5376
preserveUnknown: false,
5477
...options,
55-
}
78+
};
5679

5780
const schemaRef = useRef(schema);
58-
const rawDataRef = useRef<Record<string, unknown>>({});
5981

60-
const recalculateState = useCallback((searchString: string) => {
61-
const params = new URLSearchParams(searchString);
62-
63-
}, [])
82+
const [state, setState] = useState<UrlState<T>>({
83+
data: null,
84+
isError: false,
85+
error: null,
86+
});
87+
const stateRef = useRef(state);
88+
stateRef.current = state;
89+
90+
const recalculateState = useCallback(
91+
(searchString: string) => {
92+
const params = new URLSearchParams(searchString);
93+
const object = urlParamsToObject(params);
94+
95+
const validationResult = schemaRef.current.safeParse(object);
96+
97+
const result = validationResult.success
98+
? { success: true, data: validationResult.data, error: null }
99+
: { success: false, data: null, error: validationResult.error };
100+
101+
if (options?.preserveUnknown) {
102+
result.data = { ...object, ...result.data };
103+
}
104+
105+
setState({
106+
data: result.data,
107+
isError: !result.success,
108+
error: result.error,
109+
});
110+
},
111+
[options?.preserveUnknown],
112+
);
64113

65114
useEffect(() => {
66-
subscribeToHistory(recalculateState)
115+
subscribeToHistory(recalculateState);
67116

68117
return () => {
69-
unsubscribeFromHistory(recalculateState)
70-
}
71-
}, []);
118+
unsubscribeFromHistory(recalculateState);
119+
};
120+
}, [recalculateState]);
121+
122+
const push = usePush();
123+
124+
const reset = useCallback<UrlStateMethods<T>['reset']>(() => {
125+
const href = `?${new URLSearchParams({}).toString()}`;
126+
push(href);
127+
}, [push]);
128+
129+
const replace = useCallback<UrlStateMethods<T>['replace']>(
130+
(data) => {
131+
const href = `?${new URLSearchParams(data).toString()}`;
132+
push(href);
133+
},
134+
[push],
135+
);
136+
137+
const setValue = useCallback<UrlStateMethods<T>['setValue']>(
138+
(key, value) => {
139+
const href = `?${new URLSearchParams({
140+
...stateRef.current.data,
141+
[key]: value,
142+
}).toString()}`;
143+
push(href);
144+
},
145+
[push],
146+
);
147+
148+
return { ...state, replace, setValue, reset };
149+
}
72150

151+
function useTest() {
152+
const rez = useUrlState(
153+
z.object({
154+
name: z.string(),
155+
age: z.number(),
156+
birthDate: z.date().optional(),
157+
}),
158+
);
73159

74-
return {
75-
data: schema.parse({ name: 'John', age: 42 }),
76-
isError: false,
77-
}
160+
rez.setValue('age', 10);
161+
rez.setValue('birthDate', new Date());
162+
163+
console.log(rez.data?.birthDate);
164+
165+
rez.replace({ name: 'test', age: 10, birthDate: new Date() });
166+
rez.replace({ name: 'test', age: 10 });
78167
}

packages/core/src/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export function urlParamsToObject(params: URLSearchParams) {
2+
const object: Record<string, string | string[]> = {};
3+
4+
for (const [key, value] of params.entries()) {
5+
if (!object[key]) {
6+
object[key] = value;
7+
continue;
8+
}
9+
10+
const currentValue = object[key];
11+
if (typeof currentValue === 'string') {
12+
object[key] = [currentValue, value];
13+
} else {
14+
currentValue.push(value);
15+
}
16+
}
17+
18+
return object;
19+
}

0 commit comments

Comments
 (0)