Skip to content
Open
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
34 changes: 24 additions & 10 deletions src/useLocalStorageValue/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type {UseStorageValueOptions, UseStorageValueResult} from '../useStorageValue/index.js';
import type {
UseStorageValueDeferredOptions,
UseStorageValueOptions,
UseStorageValueResult,
} from '../useStorageValue/index.js';
import {useStorageValue} from '../useStorageValue/index.js';
import {isBrowser, noop} from '../util/const.js';

Expand All @@ -10,20 +14,30 @@ try {
IS_LOCAL_STORAGE_AVAILABLE = false;
}

type UseLocalStorageValue = <
Type,
Default extends Type = Type,
Initialize extends boolean | undefined = boolean | undefined,
>(
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
) => UseStorageValueResult<Type, Default, Initialize>;
type UseLocalStorageValue = {
<Type, Default extends Type = Type>(
key: string,
options: UseStorageValueDeferredOptions<Type>,
): UseStorageValueResult<Type, Default, false>;
<Type, Default extends Type = Type>(
key: string,
options?: UseStorageValueOptions<Type, true | undefined>,
): UseStorageValueResult<Type, Default, true>;
<Type, Default extends Type = Type, Initialize extends boolean | undefined = boolean | undefined>(
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
): UseStorageValueResult<Type, Default, Initialize>;
};

/**
* Manages a single localStorage key.
*/
export const useLocalStorageValue: UseLocalStorageValue = IS_LOCAL_STORAGE_AVAILABLE
? (key, options) => useStorageValue(localStorage, key, options)
? <Type, Default extends Type = Type, Initialize extends boolean | undefined = boolean | undefined>(
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
): UseStorageValueResult<Type, Default, Initialize> =>
useStorageValue<Type, Default, Initialize>(localStorage, key, options)
: <Type, Default extends Type = Type, Initialize extends boolean | undefined = boolean | undefined>(
_key: string,
_options?: UseStorageValueOptions<Type, Initialize>,
Expand Down
30 changes: 30 additions & 0 deletions src/useLocalStorageValue/index.types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {expectTypeOf} from 'vitest';
import {useLocalStorageValue} from './index.js';

declare const dynamicFlag: boolean;

/**
* Type-level regression suite -- see `useStorageValue/index.types.test.ts` for
* the rationale. The wrapper declares its own overloads, so it needs its own
* assertions: a change to `useStorageValue` alone cannot keep it honest.
*/
export function useLocalStorageValueTypes(): void {
expectTypeOf(useLocalStorageValue<string>('key').value).toEqualTypeOf<string>();
expectTypeOf(useLocalStorageValue('key', {defaultValue: 'default'}).value).toEqualTypeOf<string>();
expectTypeOf(useLocalStorageValue<string>('key', {defaultValue: 'default'}).value).toEqualTypeOf<string>();
expectTypeOf(useLocalStorageValue<string>('key', {initializeWithValue: true}).value).toEqualTypeOf<string>();
expectTypeOf(useLocalStorageValue<string>('key', {initializeWithValue: undefined}).value).toEqualTypeOf<string>();

expectTypeOf(useLocalStorageValue<string>('key', {initializeWithValue: false}).value).toEqualTypeOf<
string | undefined
>();
expectTypeOf(useLocalStorageValue('key', {defaultValue: 'default', initializeWithValue: false}).value).toEqualTypeOf<
string | undefined
>();
expectTypeOf(useLocalStorageValue<string>('key', {initializeWithValue: dynamicFlag}).value).toEqualTypeOf<
string | undefined
>();

expectTypeOf(useLocalStorageValue<string, string, true>('key').value).toEqualTypeOf<string>();
expectTypeOf(useLocalStorageValue<string, string, false>('key').value).toEqualTypeOf<string | undefined>();
}
34 changes: 24 additions & 10 deletions src/useSessionStorageValue/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type {UseStorageValueOptions, UseStorageValueResult} from '../useStorageValue/index.js';
import type {
UseStorageValueDeferredOptions,
UseStorageValueOptions,
UseStorageValueResult,
} from '../useStorageValue/index.js';
import {useStorageValue} from '../useStorageValue/index.js';
import {isBrowser, noop} from '../util/const.js';

Expand All @@ -10,20 +14,30 @@ try {
IS_SESSION_STORAGE_AVAILABLE = false;
}

type UseSessionStorageValue = <
Type,
Default extends Type = Type,
Initialize extends boolean | undefined = boolean | undefined,
>(
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
) => UseStorageValueResult<Type, Default, Initialize>;
type UseSessionStorageValue = {
<Type, Default extends Type = Type>(
key: string,
options: UseStorageValueDeferredOptions<Type>,
): UseStorageValueResult<Type, Default, false>;
<Type, Default extends Type = Type>(
key: string,
options?: UseStorageValueOptions<Type, true | undefined>,
): UseStorageValueResult<Type, Default, true>;
<Type, Default extends Type = Type, Initialize extends boolean | undefined = boolean | undefined>(
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
): UseStorageValueResult<Type, Default, Initialize>;
};

/**
* Manages a single sessionStorage key.
*/
export const useSessionStorageValue: UseSessionStorageValue = IS_SESSION_STORAGE_AVAILABLE
? (key, options) => useStorageValue(sessionStorage, key, options)
? <Type, Default extends Type = Type, Initialize extends boolean | undefined = boolean | undefined>(
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
): UseStorageValueResult<Type, Default, Initialize> =>
useStorageValue<Type, Default, Initialize>(sessionStorage, key, options)
: <Type, Default extends Type = Type, Initialize extends boolean | undefined = boolean | undefined>(
_key: string,
_options?: UseStorageValueOptions<Type, Initialize>,
Expand Down
30 changes: 30 additions & 0 deletions src/useSessionStorageValue/index.types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {expectTypeOf} from 'vitest';
import {useSessionStorageValue} from './index.js';

declare const dynamicFlag: boolean;

/**
* Type-level regression suite -- see `useStorageValue/index.types.test.ts` for
* the rationale. The wrapper declares its own overloads, so it needs its own
* assertions: a change to `useStorageValue` alone cannot keep it honest.
*/
export function useSessionStorageValueTypes(): void {
expectTypeOf(useSessionStorageValue<string>('key').value).toEqualTypeOf<string>();
expectTypeOf(useSessionStorageValue('key', {defaultValue: 'default'}).value).toEqualTypeOf<string>();
expectTypeOf(useSessionStorageValue<string>('key', {defaultValue: 'default'}).value).toEqualTypeOf<string>();
expectTypeOf(useSessionStorageValue<string>('key', {initializeWithValue: true}).value).toEqualTypeOf<string>();
expectTypeOf(useSessionStorageValue<string>('key', {initializeWithValue: undefined}).value).toEqualTypeOf<string>();

expectTypeOf(useSessionStorageValue<string>('key', {initializeWithValue: false}).value).toEqualTypeOf<
string | undefined
>();
expectTypeOf(
useSessionStorageValue('key', {defaultValue: 'default', initializeWithValue: false}).value,
).toEqualTypeOf<string | undefined>();
expectTypeOf(useSessionStorageValue<string>('key', {initializeWithValue: dynamicFlag}).value).toEqualTypeOf<
string | undefined
>();

expectTypeOf(useSessionStorageValue<string, string, true>('key').value).toEqualTypeOf<string>();
expectTypeOf(useSessionStorageValue<string, string, false>('key').value).toEqualTypeOf<string | undefined>();
}
23 changes: 23 additions & 0 deletions src/useStorageValue/index.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,29 @@ describe('useStorageValue', () => {
expect(expectResultValue(result.all[0]).value).toBe('bar');
});

it('should fetch value on first render in case `initializeWithValue` option is set to undefined', async () => {
const {result} = await renderHook(() =>
useStorageValue<string>(
newStorage(() => '"bar"'),
'foo',
{initializeWithValue: undefined},
),
);

expect(expectResultValue(result.all[0]).value).toBe('bar');
});

it('should yield null in case `defaultValue` option is set to undefined', async () => {
const {result} = await renderHook(() =>
// `exactOptionalPropertyTypes` rejects the explicit `undefined` here, but
// consumers without it -- and every JS consumer -- can still pass one.
// @ts-expect-error -- deliberately passing what the option type forbids
useStorageValue<string>(newStorage(), 'foo', {defaultValue: undefined}),
);

expect(expectResultValue(result).value).toBe(null);
});

it('should set storage value on .set() call', async () => {
const {result} = await renderHook(() => useStorageValue<string>(newStorage(), 'foo'));

Expand Down
45 changes: 40 additions & 5 deletions src/useStorageValue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ export type UseStorageValueOptions<T, InitializeWithValue extends boolean | unde
stringify?: (data: T) => string | null;
};

/**
* Options of a hook that defers the first storage read until effects run.
*
* `initializeWithValue` is required so that an options object that omits it
* cannot match the deferred overload -- matching it would put `undefined` back
* into the result type of a hook that does read the value on the first render.
*/
export type UseStorageValueDeferredOptions<T> = UseStorageValueOptions<T, false> & {
initializeWithValue: false;
};

type UseStorageValueValue<
Type,
Default extends Type = Type,
Expand All @@ -129,11 +140,29 @@ export type UseStorageValueResult<
fetch: () => void;
};

const DEFAULT_OPTIONS = {
defaultValue: null,
initializeWithValue: true,
};
export function useStorageValue<Type, Default extends Type = Type>(
storage: Storage,
key: string,
options: UseStorageValueDeferredOptions<Type>,
): UseStorageValueResult<Type, Default, false>;
export function useStorageValue<Type, Default extends Type = Type>(
storage: Storage,
key: string,
options?: UseStorageValueOptions<Type, true | undefined>,
): UseStorageValueResult<Type, Default, true>;
export function useStorageValue<
Type,
Default extends Type = Type,
Initialize extends boolean | undefined = boolean | undefined,
>(
storage: Storage,
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
): UseStorageValueResult<Type, Default, Initialize>;

/**
* Manages a single storage key.
*/
export function useStorageValue<
Type,
Default extends Type = Type,
Expand All @@ -143,7 +172,13 @@ export function useStorageValue<
key: string,
options?: UseStorageValueOptions<Type, Initialize>,
): UseStorageValueResult<Type, Default, Initialize> {
const optionsRef = useSyncedRef({...DEFAULT_OPTIONS, ...options});
const optionsRef = useSyncedRef({
...options,
// An explicitly passed `undefined` must fall back to the documented
// default instead of overriding it, the way a spread over defaults would.
defaultValue: options?.defaultValue ?? null,
initializeWithValue: options?.initializeWithValue ?? true,
});
const parse = (str: string | null, fallback: Type | null): Type | null => {
const parseFunction = optionsRef.current.parse ?? defaultParse;
return parseFunction(str, fallback);
Expand Down
80 changes: 80 additions & 0 deletions src/useStorageValue/index.types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {expectTypeOf} from 'vitest';
import type {NextState} from '../util/resolve-hook-state.js';
import type {UseStorageValueResult} from './index.js';
import {useStorageValue} from './index.js';

declare const storage: Storage;
declare const dynamicFlag: boolean;

/**
* Type-level regression suite for the `value` field of the hook result.
*
* Nothing here runs -- the assertions are checked by the type-check pass of
* `vp lint`. `undefined` may only appear in `value` when `initializeWithValue`
* is not known to resolve to `true`, since deferring the first read until
* effects run is the only case that yields `undefined` to the caller.
*
* TypeScript does not infer a trailing type parameter when an earlier one is
* passed explicitly, so every case below is repeated with an explicit `Type` --
* that is the shape in which the bug originally surfaced.
*/
export function useStorageValueValueTypes(): void {
// `initializeWithValue` omitted -- the value is read during the first render.
expectTypeOf(useStorageValue<string>(storage, 'key').value).toEqualTypeOf<string>();
expectTypeOf(useStorageValue(storage, 'key', {defaultValue: 'default'}).value).toEqualTypeOf<string>();
expectTypeOf(useStorageValue<string>(storage, 'key', {defaultValue: 'default'}).value).toEqualTypeOf<string>();

// Explicit `true` behaves like the omitted case.
expectTypeOf(
useStorageValue(storage, 'key', {defaultValue: 'default', initializeWithValue: true}).value,
).toEqualTypeOf<string>();
expectTypeOf(useStorageValue<string>(storage, 'key', {initializeWithValue: true}).value).toEqualTypeOf<string>();

// An explicit `undefined` falls back to the default, so the value is read too.
expectTypeOf(useStorageValue<string>(storage, 'key', {initializeWithValue: undefined}).value).toEqualTypeOf<string>();

// Explicit `false` defers the read, so the first render yields `undefined`.
expectTypeOf(useStorageValue(storage, 'key', {defaultValue: 'default', initializeWithValue: false}).value)
//
.toEqualTypeOf<string | undefined>();
expectTypeOf(useStorageValue<string>(storage, 'key', {initializeWithValue: false}).value).toEqualTypeOf<
string | undefined
>();

// A flag unknown at compile time may turn out to be `false`.
expectTypeOf(useStorageValue(storage, 'key', {defaultValue: 'default', initializeWithValue: dynamicFlag}).value)
//
.toEqualTypeOf<string | undefined>();
expectTypeOf(useStorageValue<string>(storage, 'key', {initializeWithValue: dynamicFlag}).value).toEqualTypeOf<
string | undefined
>();

// Explicit type arguments keep addressing the same overload they used to.
expectTypeOf(useStorageValue<string, string, true>(storage, 'key').value).toEqualTypeOf<string>();
expectTypeOf(useStorageValue<string, string, false>(storage, 'key').value).toEqualTypeOf<string | undefined>();
}

/**
* `set` receives the value type the hook yields, so an updater callback must not
* be handed an `undefined` previous state unless the read is deferred.
*/
export function useStorageValueSetTypes(): void {
expectTypeOf(useStorageValue<string>(storage, 'key').set).parameter(0).toEqualTypeOf<NextState<string, string>>();

expectTypeOf(useStorageValue<string>(storage, 'key', {initializeWithValue: false}).set)
.parameter(0)
.toEqualTypeOf<NextState<string, string | undefined>>();
}

/**
* The exported result type keeps its three parameters, and instantiating it by
* hand -- the way a consumer annotates a variable -- resolves the same way.
*/
export function useStorageValueResultTypes(): void {
expectTypeOf<UseStorageValueResult<string, string, true>['value']>().toEqualTypeOf<string>();
expectTypeOf<UseStorageValueResult<string, string, false>['value']>().toEqualTypeOf<string | undefined>();
expectTypeOf<UseStorageValueResult<string, string, boolean>['value']>().toEqualTypeOf<string | undefined>();

// Without a known `initializeWithValue`, the type stays conservative.
expectTypeOf<UseStorageValueResult<string>['value']>().toEqualTypeOf<string | undefined>();
}