Conversation
Options were resolved as `{...DEFAULT_OPTIONS, ...options}`. A spread
copies an own key even when its value is `undefined`, so
`{initializeWithValue: undefined}` overrode the `true` default and
deferred the first storage read, while `{defaultValue: undefined}`
replaced the `null` fallback handed to `parse`.
Both options resolve with `??`, the pattern useCookieValue and
useScreenOrientation already use.
`value` was typed `Type | undefined` whenever `initializeWithValue`
stayed at its type-parameter default. TypeScript does not infer a
trailing type parameter once an earlier one is passed explicitly, so
`useLocalStorageValue<string>(key)` never inferred `Initialize`, and
`{defaultValue: x}` left it at `boolean | undefined`, which distributes
over `extends false` and pulls `undefined` into the union.
Raising that default to `true` instead makes
`useLocalStorageValue<string>(key, {initializeWithValue: false})` a type
error, for the same reason. Two overloads decide the result; the
three-parameter signature stays as a third so explicit callers resolve
unchanged. `UseStorageValueDeferredOptions` requires
`initializeWithValue: false`, so options omitting it cannot match the
deferred overload.
`UseStorageValueValue` is untouched -- given a literal `Initialize` its
conditional is correct. Assertions sit in `index.types.test.ts` beside
each hook, checked by `vp lint`.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1698 +/- ##
==========================================
- Coverage 83.25% 83.23% -0.02%
==========================================
Files 62 62
Lines 842 841 -1
Branches 151 151
==========================================
- Hits 701 700 -1
Misses 16 16
Partials 125 125 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
I checked two runtime cases worth covering before treating the initialized result as definitely Against this PR's head function useMissing(storage: Storage): string {
return useStorageValue<string>(storage, 'missing').value;
}
function useLocal(): string {
return useLocalStorageValue('missing', {defaultValue: 'anonymous'}).value;
}But running the actual PR source through React
The nullability of an absent key and the SSR/unavailable-storage result are separate from whether the initial read is deferred. These are existing runtime behaviors, not a claim that this PR introduced them. The new no-options Could the regression coverage include these cases and preserve their possible result types, or explicitly change/document the runtime contract before narrowing? I used a storage fixture and real React SSR, plus a strict source-level typecheck against the PR worktree; no browser blocked-storage environment was simulated. |
valuecarried a bogusundefinedwheneverinitializeWithValuewas left at its default, forcing null checks thatcan never fire. The runtime has read storage on the first render since
17.0.1(#1002); the type never followed.Supersedes #1647, which reported the same defect and targets the same conditional.
Problem
Initializeis only inferable from a literalinitializeWithValuein the options object. Anything else leaves it atthe type-parameter default
boolean | undefined, and since it is the checked type of a conditional, it distributes:Flipping the conditional to
false extends Initialize, as #1647 does, yields the same union —falseis assignable toboolean | undefined, so the deferred branch is picked anyway. The two forms are in fact interchangeable for everyinstantiation the hooks can produce; asserting mutual assignability across
true,false,undefined,booleanandboolean | undefinedcompiles clean, and they diverge only onnever.So the defect is in the signature, not in the conditional.
Nor is raising the default to
trueenough. TypeScript does not infer a trailing type parameter once an earlier one ispassed explicitly — omitted ones take their defaults — so
Initializeis never inferred whenTypeis written out, andthe documented SSR form stops compiling:
Change
Two overloads pick the result type, with the existing three-parameter signature kept as a third:
UseStorageValueDeferredOptionsisUseStorageValueOptions<T, false>withinitializeWithValuerequired, so{defaultValue: 'x'}cannot match the deferred overload and fall through it.Defaultis inert either way — it isunreachable by inference — but it stays, along with the third overload, so
useLocalStorageValue<string, string, false>resolves exactly as before.
UseStorageValueValueis untouched. Given a literalInitialize, the original conditional is already right.Both wrappers declare their own signatures, so both get the same overload set.
One runtime fix is required to make the second overload honest. Options were resolved as
{...DEFAULT_OPTIONS, ...options}, and a spread copies an own key even when its value isundefined— so{initializeWithValue: undefined}overrode thetruedefault and deferred the read, while{defaultValue: undefined}replaced the
nullfallback handed toparse. Both now resolve with??, the patternuseCookieValueanduseScreenOrientationalready use.Resulting types
Measured by assigning each call's
valuetostringin a scratch consumer, against published26.1.0and against thepacked branch build.
useLocalStorageValue<string>('k')string | undefinedstringuseLocalStorageValue('k', {defaultValue: 'x'})string | undefinedstringuseLocalStorageValue<string>('k', {initializeWithValue: true})string | undefinedstringuseLocalStorageValue<string>('k', {initializeWithValue: undefined})string | undefinedstringuseLocalStorageValue<string>('k', {initializeWithValue: false})string | undefinedstring | undefineduseLocalStorageValue<string>('k', {initializeWithValue: flag})string | undefinedstring | undefineduseLocalStorageValue<string, string, true>('k')stringstringuseLocalStorageValue<string, string, false>('k')string | undefinedstring | undefinedNarrowing only, and never in the other direction. Every call that compiled before still compiles, so this is a patch,
not a break.
Tests
Type-level assertions in
index.types.test.tsbeside each of the three hooks — every overload, everyinitializeWithValueform, a runtimebooleanflag,set's previous-state parameter, and the explicit type-argumentcalls. Each case is repeated with an explicit
Type, which is the shape the defect hid in.The name matches neither vitest project pattern, so nothing runs;
vp lint'stypeCheckpass is the gate. Verified bybreaking an assertion:
Two DOM tests cover the option resolution:
initializeWithValue: undefinedreads on the first render,defaultValue: undefinedyieldsnull. The second needs@ts-expect-error—exactOptionalPropertyTypesrejects theexplicit
undefined, but consumers without it, and every JS consumer, can still pass one.Verification
vp fmt --check,vp lint,vp test --runandvp run buildpass on both commits. Coverage on the wrappers isunchanged at 75%; branch coverage on
useStorageValuegoes 91.52% -> 92.06%.Packed the tarball and typechecked a scratch consumer against it (
react@19,moduleResolution: NodeNext): thereproduction above exits 0, and the legacy three-type-argument calls, the
UseStorageValueResulthand annotation, thebarrel import and the
@react-hookz/web/useStorageValuesubpath all still resolve.Scope
The result type still claims
Typewhen nodefaultValueis given, though a missing key yieldsnullat runtime.Defaultis unreachable by inference (defaultValue?: T, not?: Default), so thenull | Typebranch ofUseStorageValueValueis dead. Fixing that meansvalue: string | nullforuseLocalStorageValue<string>('key')— abreak that needs its own major. Left alone here.
Checklist
initializeWithValuealready documents@default true; this makes the type agree with it