Skip to content

Commit 6924a1a

Browse files
authored
refactor(libraries): type safe local storage json (#6455)
1 parent f6d2aee commit 6924a1a

File tree

16 files changed

+160
-66
lines changed

16 files changed

+160
-66
lines changed

.changeset/tall-lies-raise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hive': patch
3+
---
4+
5+
A minor defect in Laboratory has been fixed that previously caused the application to crash when local storage was in a particular state.

packages/web/app/src/components/target/explorer/provider.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
useState,
99
} from 'react';
1010
import { startOfDay } from 'date-fns';
11-
import { resolveRange, type Period } from '@/lib/date-math';
11+
import { z } from 'zod';
12+
import { Period, resolveRange } from '@/lib/date-math';
1213
import { subDays } from '@/lib/date-time';
1314
import { useLocalStorageJson } from '@/lib/hooks';
1415
import { UTCDate } from '@date-fns/utc';
@@ -27,7 +28,7 @@ type SchemaExplorerContextType = {
2728
refreshResolvedPeriod(): void;
2829
};
2930

30-
const defaultPeriod = {
31+
const defaultPeriod: Period = {
3132
from: 'now-7d',
3233
to: 'now',
3334
};
@@ -56,11 +57,11 @@ export function SchemaExplorerProvider({ children }: { children: ReactNode }): R
5657

5758
const [isArgumentListCollapsed, setArgumentListCollapsed] = useLocalStorageJson(
5859
'hive:schema-explorer:collapsed',
59-
true,
60+
z.boolean().default(true),
6061
);
61-
const [period, setPeriod] = useLocalStorageJson<Period>(
62+
const [period, setPeriod] = useLocalStorageJson(
6263
'hive:schema-explorer:period-1',
63-
defaultPeriod,
64+
Period.default(defaultPeriod),
6465
);
6566
const [resolvedPeriod, setResolvedPeriod] = useState<Period>(() => resolveRange(period));
6667

packages/web/app/src/components/ui/changelog/changelog.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReactElement, useCallback, useEffect } from 'react';
22
import { format } from 'date-fns/format';
3+
import { z } from 'zod';
34
import { Button } from '@/components/ui/button';
45
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
56
import { useLocalStorageJson, useToggle } from '@/lib/hooks';
@@ -19,8 +20,14 @@ export function Changelog(props: { changes: Changelog[] }): ReactElement {
1920

2021
function ChangelogPopover(props: { changes: Changelog[] }) {
2122
const [isOpen, toggle] = useToggle();
22-
const [displayDot, setDisplayDot] = useLocalStorageJson<boolean>('hive:changelog:dot', false);
23-
const [readChanges, setReadChanges] = useLocalStorageJson<string[]>('hive:changelog:read', []);
23+
const [displayDot, setDisplayDot] = useLocalStorageJson(
24+
'hive:changelog:dot',
25+
z.boolean().default(false),
26+
);
27+
const [readChanges, setReadChanges] = useLocalStorageJson(
28+
'hive:changelog:read',
29+
z.array(z.string()).default([]),
30+
);
2431
const hasNewChanges = props.changes.some(change => !readChanges.includes(change.href));
2532

2633
useEffect(() => {

packages/web/app/src/lib/date-math.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
* @source https://github.com/grafana/grafana/blob/411c89012febe13323e4b8aafc8d692f4460e680/packages/grafana-data/src/datetime/datemath.ts#L1C1-L208C2
44
*/
55
import { add, format, formatISO, parse as parseDate, sub, type Duration } from 'date-fns';
6+
import { z } from 'zod';
67
import { UTCDate } from '@date-fns/utc';
78

8-
export type Period = {
9-
from: string;
10-
to: string;
11-
};
9+
export const Period = z.object({
10+
from: z.string(),
11+
to: z.string(),
12+
});
13+
export type Period = z.infer<typeof Period>;
1214

1315
export type DurationUnit = 'y' | 'M' | 'w' | 'd' | 'h' | 'm';
1416
export const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm'];
Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,75 @@
11
import { useCallback, useState } from 'react';
2+
import { z } from 'zod';
3+
import { Kit } from '../kit';
24

3-
export function useLocalStorageJson<T>(key: string, defaultValue: T) {
4-
const [value, setValue] = useState<T>(() => {
5-
const json = localStorage.getItem(key);
5+
export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInput<$Schema>) {
6+
const [key, schema, manualDefaultValue] = args as any as Args<$Schema>;
7+
// The parameter types will force the user to give a manual default
8+
// if their given Zod schema does not have default.
9+
//
10+
// We resolve that here because in the event of a Zod parse failure, we fallback
11+
// to the default value, meaning we are needing a reference to the Zod default outside
12+
// of the regular parse process.
13+
//
14+
const defaultValue =
15+
manualDefaultValue !== undefined
16+
? manualDefaultValue
17+
: Kit.ZodHelpers.isDefaultType(schema)
18+
? (schema._def.defaultValue() as z.infer<$Schema>)
19+
: Kit.never();
20+
21+
const [value, setValue] = useState<z.infer<$Schema>>(() => {
22+
// Note: `null` is returned for missing values. However Zod only kicks in
23+
// default values for `undefined`, not `null`. However-however, this is ok,
24+
// because we manually pre-compute+return the default value, thus we don't
25+
// rely on Zod's behaviour. If that changes this should have `?? undefined`
26+
// added.
27+
const storedValue = localStorage.getItem(key);
28+
29+
if (!storedValue) {
30+
return defaultValue;
31+
}
32+
33+
// todo: Some possible improvements:
34+
// - Monitor json/schema parse failures.
35+
// - Let caller choose an error strategy: 'return' / 'default' / 'throw'
636
try {
7-
const result = json ? JSON.parse(json) : defaultValue;
8-
return result;
9-
} catch (_) {
37+
return schema.parse(JSON.parse(storedValue));
38+
} catch (error) {
39+
if (error instanceof SyntaxError) {
40+
console.warn(`useLocalStorageJson: JSON parsing failed for key "${key}"`, error);
41+
} else if (error instanceof z.ZodError) {
42+
console.warn(`useLocalStorageJson: Schema validation failed for key "${key}"`, error);
43+
} else {
44+
Kit.neverCatch(error);
45+
}
1046
return defaultValue;
1147
}
1248
});
1349

1450
const set = useCallback(
15-
(value: T) => {
51+
(value: z.infer<$Schema>) => {
1652
localStorage.setItem(key, JSON.stringify(value));
1753
setValue(value);
1854
},
19-
[setValue],
55+
[key],
2056
);
2157

2258
return [value, set] as const;
2359
}
60+
61+
type ArgsInput<$Schema extends z.ZodType> =
62+
$Schema extends z.ZodDefault<z.ZodType>
63+
? [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>]
64+
: [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>, defaultValue: z.infer<$Schema>];
65+
66+
type ArgsInputGuardZodJsonSchema<$Schema extends z.ZodType> =
67+
z.infer<$Schema> extends Kit.Json.Value
68+
? $Schema
69+
: 'Error: Your Zod schema is or contains a type that is not valid JSON.';
70+
71+
type Args<$Schema extends z.ZodType> = [
72+
key: string,
73+
schema: $Schema,
74+
defaultValue?: z.infer<$Schema>,
75+
];

packages/web/app/src/lib/kit/index.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
// eslint-disable-next-line import/no-self-import
2-
export * as Kit from './index';
1+
// Storybook (or the version we are using)
2+
// is using a version of Babel that does not
3+
// support re-exporting as namespaces:
4+
//
5+
// export * as Kit from './index';
6+
//
7+
// So we have to re-export everything manually
8+
// and incur an additional index_ file for it
9+
// too:
310

4-
export * from './never';
5-
export * from './types/headers';
6-
export * from './helpers';
11+
import * as Kit from './index_';
12+
13+
// eslint-disable-next-line unicorn/prefer-export-from
14+
export { Kit };
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './never';
2+
export * from './types/headers';
3+
export * from './helpers';
4+
export * from './json';
5+
export * from './zod-helpers';

packages/web/app/src/lib/kit/json.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod';
2+
import { ZodHelpers } from './zod-helpers';
3+
4+
// eslint-disable-next-line @typescript-eslint/no-namespace
5+
export namespace Json {
6+
export const Primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]);
7+
export type Primitive = z.infer<typeof Primitive>;
8+
export const isPrimitive = ZodHelpers.createTypeGuard(Primitive);
9+
10+
export const Value: z.ZodType<Value> = z.lazy(() =>
11+
z.union([Primitive, z.array(Value), z.record(Value)]),
12+
);
13+
export type Value = Primitive | { [key: string]: Value } | Value[];
14+
export const isValue = ZodHelpers.createTypeGuard(Value);
15+
16+
export const Object: z.ZodType<Object> = z.record(Value);
17+
export type Object = { [key: string]: Value };
18+
export const isObject = ZodHelpers.createTypeGuard(Object);
19+
}

packages/web/app/src/lib/kit/never.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1+
/**
2+
* This case of thrown value is impossible.
3+
* If it happens, then that means there is a defect in our code.
4+
*/
5+
export const neverCatch = (value: unknown): never => {
6+
never({ type: 'catch', value });
7+
};
8+
19
/**
210
* This case is impossible.
3-
* If it happens, then that means there is a bug in our code.
11+
* If it happens, then that means there is a defect in our code.
412
*/
513
export const neverCase = (value: never): never => {
614
never({ type: 'case', value });
715
};
816

917
/**
1018
* This code cannot be reached.
11-
* If it is reached, then that means there is a bug in our code.
19+
* If it is reached, then that means there is a defect in our code.
1220
*/
1321
export const never: (context?: object) => never = context => {
1422
throw new Error('Something that should be impossible happened', { cause: context });
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from 'zod';
2+
3+
// eslint-disable-next-line @typescript-eslint/no-namespace
4+
export namespace ZodHelpers {
5+
export const isDefaultType = (zodType: z.ZodType): zodType is z.ZodDefault<z.ZodType> => {
6+
return 'defaultValue' in zodType._def;
7+
};
8+
9+
export const createTypeGuard =
10+
<$Schema extends z.ZodType, $Value = z.infer<$Schema>>(schema: $Schema) =>
11+
(value: unknown): value is $Value => {
12+
const result = schema.safeParse(value);
13+
return result.success;
14+
};
15+
}

0 commit comments

Comments
 (0)