mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
fix(typecheck): restore AppState hook generics (#1503)
* fix(typecheck): restore AppState hook generics * test: enforce focused type assertions * fix: remove unused spinner api metrics prop
This commit is contained in:
+2
-1
@@ -57,8 +57,9 @@
|
||||
"security:pr-scan": "bun run scripts/pr-intent-scan.ts",
|
||||
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:type-tests": "bun run scripts/typecheck-type-tests.ts",
|
||||
"smoke": "bun run build && node dist/cli.mjs --version",
|
||||
"check": "bun run smoke && bun run test:full",
|
||||
"check": "bun run smoke && bun run typecheck:type-tests && bun run test:full",
|
||||
"verify:privacy": "bun run scripts/verify-no-phone-home.ts",
|
||||
"build:verified": "bun run build && bun run verify:privacy",
|
||||
"test:provider": "bun test --max-concurrency=1 src/services/api/*.test.ts src/utils/context.test.ts",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import path from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
// Root `tsc --noEmit` has a known failing baseline. This focused check
|
||||
// enforces diagnostics in type assertion files and covered implementation
|
||||
// files while quarantining dependency diagnostics until the broader baseline
|
||||
// is fixed.
|
||||
function fail(message: string): never {
|
||||
console.error(message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function normalizeFileName(fileName: string): string {
|
||||
return path.normalize(path.resolve(fileName))
|
||||
}
|
||||
|
||||
const configPath = ts.findConfigFile(
|
||||
process.cwd(),
|
||||
ts.sys.fileExists,
|
||||
'tsconfig.type-tests.json',
|
||||
)
|
||||
|
||||
if (!configPath) {
|
||||
fail('Could not find tsconfig.type-tests.json')
|
||||
}
|
||||
|
||||
const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
|
||||
const formatHost: ts.FormatDiagnosticsHost = {
|
||||
getCanonicalFileName: fileName => fileName,
|
||||
getCurrentDirectory: ts.sys.getCurrentDirectory,
|
||||
getNewLine: () => ts.sys.newLine,
|
||||
}
|
||||
|
||||
if (configFile.error) {
|
||||
console.error(ts.formatDiagnostic(configFile.error, formatHost))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const parsedConfig = ts.parseJsonConfigFileContent(
|
||||
configFile.config,
|
||||
ts.sys,
|
||||
path.dirname(configPath),
|
||||
)
|
||||
|
||||
if (parsedConfig.errors.length > 0) {
|
||||
console.error(
|
||||
ts.formatDiagnosticsWithColorAndContext(parsedConfig.errors, formatHost),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rootFileNames = parsedConfig.fileNames.map(normalizeFileName)
|
||||
const rootFileNameSet = new Set(rootFileNames)
|
||||
|
||||
if (rootFileNameSet.size === 0) {
|
||||
fail('tsconfig.type-tests.json does not include any files')
|
||||
}
|
||||
|
||||
const program = ts.createProgram({
|
||||
rootNames: parsedConfig.fileNames,
|
||||
options: parsedConfig.options,
|
||||
projectReferences: parsedConfig.projectReferences,
|
||||
})
|
||||
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program)
|
||||
const blockingDiagnostics = diagnostics.filter(diagnostic => {
|
||||
if (!diagnostic.file) {
|
||||
return true
|
||||
}
|
||||
|
||||
return rootFileNameSet.has(normalizeFileName(diagnostic.file.fileName))
|
||||
})
|
||||
|
||||
if (blockingDiagnostics.length > 0) {
|
||||
console.error(
|
||||
ts.formatDiagnosticsWithColorAndContext(blockingDiagnostics, formatHost),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ignoredDiagnostics = diagnostics.length - blockingDiagnostics.length
|
||||
const ignoredSuffix =
|
||||
ignoredDiagnostics === 0
|
||||
? ''
|
||||
: ` (${ignoredDiagnostics} dependency diagnostics ignored)`
|
||||
|
||||
console.log(
|
||||
`Focused typecheck passed: ${rootFileNameSet.size} files checked${ignoredSuffix}.`,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
setSessionSettingsCache,
|
||||
} from '../../utils/settings/settingsCache.js'
|
||||
import type { ModelOption } from '../../utils/model/modelOptions.js'
|
||||
import type { ModelSetting } from '../../utils/model/model.js'
|
||||
import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
|
||||
type SettingsModule = typeof import('../../utils/settings/settings.js')
|
||||
@@ -2582,7 +2583,7 @@ test('interactive model picker rejects models blocked by availableModels before
|
||||
...getDefaultAppState(),
|
||||
mainLoopModel: 'allowed-model',
|
||||
}
|
||||
let latestMainLoopModel = initialState.mainLoopModel
|
||||
let latestMainLoopModel: ModelSetting = initialState.mainLoopModel
|
||||
const instance = await render(
|
||||
<AppStateProvider
|
||||
initialState={initialState}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
|
||||
import { isEnvTruthy } from '../utils/envUtils.js';
|
||||
import { count } from '../utils/array.js';
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js';
|
||||
import { formatDuration, formatNumber } from '../utils/format.js';
|
||||
import type { Theme } from 'src/utils/theme.js';
|
||||
import { activityManager } from '../utils/activityManager.js';
|
||||
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js';
|
||||
@@ -213,16 +213,6 @@ function SpinnerWithVerbInner({
|
||||
const messageColor = overrideColor ?? defaultColor;
|
||||
const shimmerColor = overrideShimmerColor ?? defaultShimmerColor;
|
||||
|
||||
// Compute TTFT string here (off the 50ms animation clock) and pass to
|
||||
// SpinnerAnimationRow so it folds into the `(thought for Ns · ...)` status
|
||||
// line instead of taking a separate row. apiMetricsRef is a ref so this
|
||||
// doesn't trigger re-renders; we pick up updates on the parent's ~25x/turn
|
||||
// re-render cadence, same as the old ApiMetricsLine did.
|
||||
let ttftText: string | null = null;
|
||||
if ("external" === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) {
|
||||
ttftText = computeTtftText(apiMetricsRef.current);
|
||||
}
|
||||
|
||||
// When leader is idle but teammates are running (and we're viewing the leader),
|
||||
// show a static dim idle display instead of the animated spinner — otherwise
|
||||
// useStalledAnimation detects no new tokens after 3s and turns the spinner red.
|
||||
|
||||
@@ -79,11 +79,34 @@ function createAgent(
|
||||
agentType: string,
|
||||
source: AgentDefinition['source'] = 'userSettings',
|
||||
): AgentDefinition {
|
||||
const whenToUse = `Use ${agentType}`
|
||||
const getSystemPrompt = () => `You are ${agentType}`
|
||||
|
||||
if (source === 'built-in') {
|
||||
return {
|
||||
agentType,
|
||||
whenToUse,
|
||||
source,
|
||||
baseDir: 'built-in',
|
||||
getSystemPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
if (source === 'plugin') {
|
||||
return {
|
||||
agentType,
|
||||
whenToUse,
|
||||
source,
|
||||
plugin: 'test-plugin',
|
||||
getSystemPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentType,
|
||||
whenToUse: `Use ${agentType}`,
|
||||
whenToUse,
|
||||
source,
|
||||
getSystemPrompt: () => `You are ${agentType}`,
|
||||
getSystemPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +180,7 @@ test('sets a different active session agent from the agent menu', async () => {
|
||||
patchConsole: false,
|
||||
})
|
||||
let callbackAgent: AgentDefinition | undefined
|
||||
let latestAgent = initialState.agent
|
||||
let latestAgent: string | undefined = initialState.agent
|
||||
|
||||
root.render(
|
||||
<AppStateProvider
|
||||
@@ -220,7 +243,7 @@ test('sets the effective agent definition for a shadowed selected row', async ()
|
||||
patchConsole: false,
|
||||
})
|
||||
let callbackAgent: AgentDefinition | undefined
|
||||
let latestAgent = initialState.agent
|
||||
let latestAgent: string | undefined = initialState.agent
|
||||
|
||||
root.render(
|
||||
<AppStateProvider
|
||||
|
||||
@@ -4759,7 +4759,7 @@ export function REPL({
|
||||
{"external" === 'ant' && <TungstenLiveMonitor />}
|
||||
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
|
||||
<Box flexGrow={1} />
|
||||
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
|
||||
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
|
||||
{!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && <BriefIdleStatus />}
|
||||
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
||||
</>} bottom={<Box flexDirection={isBuddyEnabled() && companionNarrow ? 'column' : 'row'} width="100%" alignItems={isBuddyEnabled() && companionNarrow ? undefined : 'flex-end'}>
|
||||
|
||||
+30
-19
@@ -26,6 +26,7 @@ import { type AppState, type AppStateStore, getDefaultAppState } from './AppStat
|
||||
// can incrementally move off the .tsx import and stop pulling React.
|
||||
export { type AppState, type AppStateStore, type CompletionBoundary, getDefaultAppState, IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState } from './AppStateStore.js';
|
||||
export const AppStoreContext = React.createContext<AppStateStore | null>(null);
|
||||
type IfAny<T, Y, N> = 0 extends 1 & T ? Y : N;
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
initialState?: AppState;
|
||||
@@ -35,7 +36,7 @@ type Props = {
|
||||
}) => void;
|
||||
};
|
||||
const HasAppStateContext = React.createContext<boolean>(false);
|
||||
export function AppStateProvider(t0) {
|
||||
export function AppStateProvider(t0: Props): React.ReactNode {
|
||||
const $ = _c(13);
|
||||
const {
|
||||
children,
|
||||
@@ -46,7 +47,7 @@ export function AppStateProvider(t0) {
|
||||
if (hasAppStateContext) {
|
||||
throw new Error("AppStateProvider can not be nested within another AppStateProvider");
|
||||
}
|
||||
let t1;
|
||||
let t1: () => AppStateStore;
|
||||
if ($[0] !== initialState || $[1] !== onChangeAppState) {
|
||||
t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState);
|
||||
$[0] = initialState;
|
||||
@@ -55,8 +56,8 @@ export function AppStateProvider(t0) {
|
||||
} else {
|
||||
t1 = $[2];
|
||||
}
|
||||
const [store] = useState(t1);
|
||||
let t2;
|
||||
const [store] = useState<AppStateStore>(t1);
|
||||
let t2: () => void;
|
||||
if ($[3] !== store) {
|
||||
t2 = () => {
|
||||
const {
|
||||
@@ -72,7 +73,7 @@ export function AppStateProvider(t0) {
|
||||
} else {
|
||||
t2 = $[4];
|
||||
}
|
||||
let t3;
|
||||
let t3: React.DependencyList;
|
||||
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t3 = [];
|
||||
$[5] = t3;
|
||||
@@ -80,9 +81,9 @@ export function AppStateProvider(t0) {
|
||||
t3 = $[5];
|
||||
}
|
||||
useEffect(t2, t3);
|
||||
let t4;
|
||||
let t4: (source: SettingSource) => void;
|
||||
if ($[6] !== store.setState) {
|
||||
t4 = source => applySettingsChange(source, store.setState);
|
||||
t4 = (source: SettingSource) => applySettingsChange(source, store.setState);
|
||||
$[6] = store.setState;
|
||||
$[7] = t4;
|
||||
} else {
|
||||
@@ -109,7 +110,7 @@ export function AppStateProvider(t0) {
|
||||
}
|
||||
return t6;
|
||||
}
|
||||
function _temp(prev) {
|
||||
function _temp(prev: AppState): AppState {
|
||||
return {
|
||||
...prev,
|
||||
toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext)
|
||||
@@ -140,16 +141,18 @@ function useAppStore(): AppStateStore {
|
||||
* const { text, promptId } = useAppState(s => s.promptSuggestion) // good
|
||||
* ```
|
||||
*/
|
||||
export function useAppState(selector) {
|
||||
export function useAppState<T>(selector: (state: AppState) => T): T;
|
||||
export function useAppState<T>(selector: IfAny<T, T, never>): any;
|
||||
export function useAppState<T>(selector: (state: AppState) => T): T {
|
||||
const store = useAppStore();
|
||||
const selectorRef = React.useRef(selector);
|
||||
const storeRef = React.useRef(store);
|
||||
const selectorRef = React.useRef<(state: AppState) => T>(selector);
|
||||
const storeRef = React.useRef<AppStateStore>(store);
|
||||
// Update refs during render so get() always calls the latest selector/store
|
||||
// without creating a new function identity that would trigger useSyncExternalStore
|
||||
// to re-sync and cause re-render loops.
|
||||
selectorRef.current = selector;
|
||||
storeRef.current = store;
|
||||
const get = React.useCallback(() => {
|
||||
const get = React.useCallback((): T => {
|
||||
return selectorRef.current(storeRef.current.getState());
|
||||
}, []);
|
||||
return useSyncExternalStore(store.subscribe, get, get);
|
||||
@@ -160,31 +163,39 @@ export function useAppState(selector) {
|
||||
* Returns a stable reference that never changes -- components using only
|
||||
* this hook will never re-render from state changes.
|
||||
*/
|
||||
export function useSetAppState() {
|
||||
export function useSetAppState(): AppStateStore['setState'] {
|
||||
return useAppStore().setState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the store directly (for passing getState/setState to non-React code).
|
||||
*/
|
||||
export function useAppStateStore() {
|
||||
export function useAppStateStore(): AppStateStore {
|
||||
return useAppStore();
|
||||
}
|
||||
const NOOP_SUBSCRIBE = () => () => {};
|
||||
const NOOP_SUBSCRIBE: AppStateStore['subscribe'] = () => () => {};
|
||||
|
||||
/**
|
||||
* Safe version of useAppState that returns undefined if called outside of AppStateProvider.
|
||||
* Useful for components that may be rendered in contexts where AppStateProvider isn't available.
|
||||
*/
|
||||
export function useAppStateMaybeOutsideOfProvider(selector) {
|
||||
export function useAppStateMaybeOutsideOfProvider<T>(
|
||||
selector: (state: AppState) => T,
|
||||
): T | undefined;
|
||||
export function useAppStateMaybeOutsideOfProvider<T>(
|
||||
selector: IfAny<T, T, never>,
|
||||
): any;
|
||||
export function useAppStateMaybeOutsideOfProvider<T>(
|
||||
selector: (state: AppState) => T,
|
||||
): T | undefined {
|
||||
const store = useContext(AppStoreContext);
|
||||
const selectorRef = React.useRef(selector);
|
||||
const storeRef = React.useRef(store);
|
||||
const selectorRef = React.useRef<(state: AppState) => T>(selector);
|
||||
const storeRef = React.useRef<AppStateStore | null>(store);
|
||||
// Update refs during render so get() always calls the latest selector/store
|
||||
// without creating a new function identity.
|
||||
selectorRef.current = selector;
|
||||
storeRef.current = store;
|
||||
const get = React.useCallback(() => {
|
||||
const get = React.useCallback((): T | undefined => {
|
||||
return storeRef.current ? selectorRef.current(storeRef.current.getState()) : undefined;
|
||||
}, []);
|
||||
return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, get);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { AppStateStore } from './AppState.js'
|
||||
import {
|
||||
useAppState,
|
||||
useAppStateMaybeOutsideOfProvider,
|
||||
useAppStateStore,
|
||||
useSetAppState,
|
||||
} from './AppState.js'
|
||||
|
||||
type Assert<T extends true> = T
|
||||
type IsAny<T> = 0 extends 1 & T ? true : false
|
||||
type IsEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends <
|
||||
T,
|
||||
>() => T extends B ? 1 : 2
|
||||
? true
|
||||
: false
|
||||
|
||||
function assertAppStateHookTypes(): void {
|
||||
const anySelector = ((state: unknown) => state) as any
|
||||
|
||||
const verbose = useAppState(state => state.verbose)
|
||||
type _VerboseIsBoolean = Assert<IsEqual<typeof verbose, boolean>>
|
||||
type _VerboseIsNotAny = Assert<IsAny<typeof verbose> extends false ? true : false>
|
||||
|
||||
const anySelected = useAppState(anySelector)
|
||||
type _AnySelectorStaysAny = Assert<
|
||||
IsAny<typeof anySelected> extends true ? true : false
|
||||
>
|
||||
|
||||
// @ts-expect-error Compatibility overload is reserved for compiler-erased `any` selectors.
|
||||
useAppState((state: { missing: string }) => state.missing)
|
||||
|
||||
const maybeVerbose = useAppStateMaybeOutsideOfProvider(
|
||||
state => state.verbose,
|
||||
)
|
||||
type _MaybeVerboseIsOptionalBoolean = Assert<
|
||||
IsEqual<typeof maybeVerbose, boolean | undefined>
|
||||
>
|
||||
type _MaybeVerboseIsNotAny = Assert<
|
||||
IsAny<typeof maybeVerbose> extends false ? true : false
|
||||
>
|
||||
|
||||
const maybeAnySelected = useAppStateMaybeOutsideOfProvider(anySelector)
|
||||
type _MaybeAnySelectorStaysAny = Assert<
|
||||
IsAny<typeof maybeAnySelected> extends true ? true : false
|
||||
>
|
||||
|
||||
// @ts-expect-error Compatibility overload is reserved for compiler-erased `any` selectors.
|
||||
useAppStateMaybeOutsideOfProvider((state: { missing: string }) => state.missing)
|
||||
|
||||
const setAppState = useSetAppState()
|
||||
type _SetAppStateIsStoreSetter = Assert<
|
||||
IsEqual<typeof setAppState, AppStateStore['setState']>
|
||||
>
|
||||
type _SetAppStateIsNotAny = Assert<
|
||||
IsAny<typeof setAppState> extends false ? true : false
|
||||
>
|
||||
|
||||
const store = useAppStateStore()
|
||||
type _StoreIsAppStateStore = Assert<IsEqual<typeof store, AppStateStore>>
|
||||
type _StoreIsNotAny = Assert<IsAny<typeof store> extends false ? true : false>
|
||||
|
||||
const additionalDirectoryKeys = useAppState(state =>
|
||||
Array.from(state.toolPermissionContext.additionalWorkingDirectories.keys()),
|
||||
)
|
||||
type _AdditionalDirectoryKeysAreStrings = Assert<
|
||||
IsEqual<typeof additionalDirectoryKeys, string[]>
|
||||
>
|
||||
type _AdditionalDirectoryKeysAreNotAny = Assert<
|
||||
IsAny<typeof additionalDirectoryKeys> extends false ? true : false
|
||||
>
|
||||
|
||||
const hasActiveOverlay = useAppState(state =>
|
||||
state.activeOverlays.has('test-overlay'),
|
||||
)
|
||||
type _ActiveOverlayCheckIsBoolean = Assert<
|
||||
IsEqual<typeof hasActiveOverlay, boolean>
|
||||
>
|
||||
type _ActiveOverlayCheckIsNotAny = Assert<
|
||||
IsAny<typeof hasActiveOverlay> extends false ? true : false
|
||||
>
|
||||
|
||||
const registeredToolNames = useAppState(state =>
|
||||
state.replContext
|
||||
? Array.from(state.replContext.registeredTools.keys())
|
||||
: [],
|
||||
)
|
||||
type _RegisteredToolNamesAreStrings = Assert<
|
||||
IsEqual<typeof registeredToolNames, string[]>
|
||||
>
|
||||
type _RegisteredToolNamesAreNotAny = Assert<
|
||||
IsAny<typeof registeredToolNames> extends false ? true : false
|
||||
>
|
||||
|
||||
void verbose
|
||||
void anySelected
|
||||
void maybeVerbose
|
||||
void maybeAnySelected
|
||||
void setAppState
|
||||
void store
|
||||
void additionalDirectoryKeys
|
||||
void hasActiveOverlay
|
||||
void registeredToolNames
|
||||
}
|
||||
|
||||
void assertAppStateHookTypes
|
||||
+13
-5
@@ -4,11 +4,19 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type DeepImmutable<T> = T extends any[]
|
||||
? readonly DeepImmutable<T[number]>[]
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepImmutable<T[K]> }
|
||||
: T
|
||||
export type DeepImmutable<T> = T extends (...args: any[]) => any
|
||||
? T
|
||||
: T extends ReadonlyMap<infer K, infer V>
|
||||
? ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>>
|
||||
: T extends ReadonlySet<infer V>
|
||||
? ReadonlySet<DeepImmutable<V>>
|
||||
: T extends readonly unknown[]
|
||||
? number extends T['length']
|
||||
? readonly DeepImmutable<T[number]>[]
|
||||
: { readonly [K in keyof T]: DeepImmutable<T[K]> }
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepImmutable<T[K]> }
|
||||
: T
|
||||
|
||||
export type Permutations<T extends string, U extends string = T> = T extends T
|
||||
? T | `${T}${Permutations<Exclude<U, T>>}`
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { DeepImmutable } from './utils.js'
|
||||
|
||||
type Assert<T extends true> = T
|
||||
type IsEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends <
|
||||
T,
|
||||
>() => T extends B ? 1 : 2
|
||||
? true
|
||||
: false
|
||||
|
||||
type ImmutableReadonlyMap = DeepImmutable<
|
||||
ReadonlyMap<string, { items: string[] }>
|
||||
>
|
||||
type ImmutableReadonlyMapValue = NonNullable<
|
||||
ReturnType<ImmutableReadonlyMap['get']>
|
||||
>
|
||||
type _ReadonlyMapValueIsDeepImmutable = Assert<
|
||||
IsEqual<ImmutableReadonlyMapValue, { readonly items: readonly string[] }>
|
||||
>
|
||||
|
||||
type ImmutableReadonlySet = DeepImmutable<ReadonlySet<{ items: string[] }>>
|
||||
type ImmutableReadonlySetValue =
|
||||
ImmutableReadonlySet extends ReadonlySet<infer Value> ? Value : never
|
||||
type _ReadonlySetValueIsDeepImmutable = Assert<
|
||||
IsEqual<ImmutableReadonlySetValue, { readonly items: readonly string[] }>
|
||||
>
|
||||
|
||||
type ImmutableReadonlyTuple = DeepImmutable<
|
||||
readonly [{ a: string[] }, { b: number[] }]
|
||||
>
|
||||
type _ReadonlyTuplePreservesPositions = Assert<
|
||||
IsEqual<
|
||||
ImmutableReadonlyTuple,
|
||||
readonly [
|
||||
{ readonly a: readonly string[] },
|
||||
{ readonly b: readonly number[] },
|
||||
]
|
||||
>
|
||||
>
|
||||
|
||||
function assertReadonlyCollectionTypes(
|
||||
readonlyMap: ImmutableReadonlyMap,
|
||||
readonlySet: ImmutableReadonlySet,
|
||||
readonlyTuple: ImmutableReadonlyTuple,
|
||||
): void {
|
||||
const mapValue = readonlyMap.get('test')
|
||||
if (mapValue) {
|
||||
// @ts-expect-error DeepImmutable keeps ReadonlyMap APIs but freezes nested values.
|
||||
mapValue.items.push('mutates')
|
||||
}
|
||||
|
||||
for (const setValue of readonlySet) {
|
||||
// @ts-expect-error DeepImmutable keeps ReadonlySet APIs but freezes nested values.
|
||||
setValue.items.push('mutates')
|
||||
}
|
||||
|
||||
// @ts-expect-error DeepImmutable preserves tuple positions and freezes nested values.
|
||||
readonlyTuple[0].a.push('mutates')
|
||||
|
||||
// @ts-expect-error DeepImmutable preserves readonly tuple arity.
|
||||
readonlyTuple[2]
|
||||
}
|
||||
|
||||
void assertReadonlyCollectionTypes
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"files": [
|
||||
"src/state/AppState.types.test.tsx",
|
||||
"src/state/AppState.tsx",
|
||||
"src/types/utils.types.test.ts",
|
||||
"src/types/utils.ts"
|
||||
],
|
||||
"include": []
|
||||
}
|
||||
Reference in New Issue
Block a user