feat(record): add generic record context APIs

This commit is contained in:
Maofeng
2026-09-28 17:23:59 +08:00
parent 93373adb9a
commit 3eb0246c40
11 changed files with 324 additions and 0 deletions
+19
View File
@@ -228,6 +228,23 @@
"vitest": "^4.1.10",
},
},
"packages/record": {
"name": "@workspace/record",
"version": "0.0.0",
"dependencies": {
"es-toolkit": "^1.50.0",
"react": "^19.2.6",
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10",
},
},
"packages/search": {
"name": "@workspace/search",
"version": "0.0.0",
@@ -1066,6 +1083,8 @@
"@workspace/preferences": ["@workspace/preferences@workspace:packages/preferences"],
"@workspace/record": ["@workspace/record@workspace:packages/record"],
"@workspace/search": ["@workspace/search@workspace:packages/search"],
"@workspace/ui": ["@workspace/ui@workspace:packages/ui"],
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@workspace/record",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"es-toolkit": "^1.50.0",
"react": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
".": "./src/index.ts"
}
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./optional-record-provider"
export * from "./record-context"
export * from "./record-provider"
export * from "./records-iterator"
export * from "./use-field-value"
export * from "./use-record"
export * from "./types"
@@ -0,0 +1,22 @@
import type { ReactNode } from "react"
import { RecordProvider } from "./record-provider"
import type { RecordValue } from "./types"
export type OptionalRecordProviderProps<
TRecord extends RecordValue = RecordValue,
> = {
children: ReactNode
value?: TRecord | null
}
/** Adds a nested record context only when a record is available. */
export function OptionalRecordProvider<
TRecord extends RecordValue = RecordValue,
>({ value, children }: OptionalRecordProviderProps<TRecord>) {
return value === undefined || value === null ? (
children
) : (
<RecordProvider value={value}>{children}</RecordProvider>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { createContext } from "react"
import type { RecordValue } from "./types"
export const RecordContext = createContext<RecordValue | undefined>(undefined)
RecordContext.displayName = "RecordContext"
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from "react"
import { RecordContext } from "./record-context"
import type { RecordValue } from "./types"
export type RecordProviderProps<TRecord extends RecordValue = RecordValue> = {
children: ReactNode
value: TRecord
}
/** Makes one record available to all descendant record-aware components. */
export function RecordProvider<TRecord extends RecordValue = RecordValue>({
children,
value,
}: RecordProviderProps<TRecord>) {
return (
<RecordContext.Provider value={value}>{children}</RecordContext.Provider>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { Fragment, type Key, type ReactNode } from "react"
import { RecordProvider } from "./record-provider"
import type { RecordValue } from "./types"
export type RecordKey<TRecord extends RecordValue> =
| ((record: TRecord, index: number) => Key)
| (TRecord extends object ? keyof TRecord & string : never)
export type RecordsIteratorProps<TRecord extends RecordValue = RecordValue> = {
children?: ReactNode
data?: readonly TRecord[]
iterator?: (record: TRecord, index: number) => ReactNode
recordKey?: RecordKey<TRecord>
separator?: (index: number) => ReactNode
}
/** Renders children once per record with an isolated record context. */
export function RecordsIterator<TRecord extends RecordValue = RecordValue>({
children,
data,
iterator,
recordKey,
separator,
}: RecordsIteratorProps<TRecord>) {
if (!data?.length || !(iterator || children)) {
return null
}
return (
<>
{data.map((record, index) => (
<Fragment key={resolveRecordKey(record, index, recordKey)}>
{index > 0 && separator ? separator(index) : null}
<RecordProvider value={record}>
{iterator ? iterator(record, index) : children}
</RecordProvider>
</Fragment>
))}
</>
)
}
function resolveRecordKey<TRecord extends RecordValue>(
record: TRecord,
index: number,
recordKey?: RecordKey<TRecord>
): Key {
if (typeof recordKey === "function") return recordKey(record, index)
if (recordKey !== undefined) {
if (!isIndexable(record)) return index
const value = Reflect.get(record, recordKey)
return isKey(value) ? value : index
}
if (isKey(record)) return record
if (!isIndexable(record)) return index
const id = Reflect.get(record, "id")
return isKey(id) ? id : index
}
function isKey(value: unknown): value is Key {
return (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "bigint"
)
}
function isIndexable(value: unknown): value is object {
return (
(typeof value === "object" && value !== null) || typeof value === "function"
)
}
+84
View File
@@ -0,0 +1,84 @@
type AtomicValue =
| bigint
| boolean
| Date
| ((...args: never[]) => unknown)
| null
| number
| RegExp
| string
| symbol
| undefined
type IsAny<Value> = 0 extends 1 & Value ? true : false
type PreviousDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
type PathSegment<
Key extends string | number,
Value,
Depth extends number,
> = Value extends AtomicValue
? `${Key}`
: Value extends readonly (infer Item)[]
?
| `${Key}`
| `${Key}.${number}`
| (Item extends AtomicValue
? never
: `${Key}.${number}.${RecordPath<Item, PreviousDepth[Depth]>}`)
: `${Key}` | `${Key}.${RecordPath<Value, PreviousDepth[Depth]>}`
/** Any non-nullish value that can be exposed through the record context. */
export type RecordValue = NonNullable<unknown>
/** A value returned from a record field when its exact type is not known. */
export type SourceValue = unknown
/** Dot-separated paths into a record, including numeric array segments. */
export type RecordPath<Value, Depth extends number = 6> =
IsAny<Value> extends true
? string
: Depth extends 0
? never
: Value extends AtomicValue
? never
: {
[Key in keyof Value & (string | number)]: PathSegment<
Key,
Value[Key],
Depth
>
}[keyof Value & (string | number)]
/** Extracts valid record paths and falls back to string for open records. */
export type ExtractRecordPaths<TRecord extends RecordValue> =
IsAny<TRecord> extends true
? string
: TRecord extends object
? keyof TRecord extends never
? string
: string extends keyof TRecord
? string
: RecordPath<TRecord>
: string
/** Resolves the value type at a dot-separated record path. */
export type RecordPathValue<
Value,
Path extends string,
> = Path extends keyof Value
? Value[Path]
: Path extends `${infer Head}.${infer Tail}`
? Head extends keyof Value
? RecordPathValue<NonNullable<Value[Head]>, Tail>
: Value extends readonly (infer Item)[]
? Head extends `${number}`
? RecordPathValue<NonNullable<Item>, Tail>
: unknown
: unknown
: Value extends readonly (infer Item)[]
? Path extends `${number}`
? Item
: unknown
: unknown
+30
View File
@@ -0,0 +1,30 @@
import { get } from "es-toolkit/compat"
import type { ExtractRecordPaths, RecordPathValue, RecordValue } from "./types"
import { useRecord } from "./use-record"
export type UseFieldValueOptions<
TRecord extends RecordValue = RecordValue,
TSource extends ExtractRecordPaths<TRecord> = ExtractRecordPaths<TRecord>,
> = {
defaultValue?: RecordPathValue<TRecord, TSource>
record?: TRecord | null
source: TSource
}
/** Reads a direct or nested field from a record override or context. */
export function useFieldValue<
TRecord extends RecordValue = RecordValue,
TSource extends ExtractRecordPaths<TRecord> = ExtractRecordPaths<TRecord>,
>({
defaultValue,
record: fallbackRecord,
source,
}: UseFieldValueOptions<TRecord, TSource>):
| RecordPathValue<TRecord, TSource>
| undefined {
const record = useRecord({ record: fallbackRecord })
return get(record, source, defaultValue) as
| RecordPathValue<TRecord, TSource>
| undefined
}
+16
View File
@@ -0,0 +1,16 @@
import { useContext } from "react"
import { RecordContext } from "./record-context"
import type { RecordValue } from "./types"
export type UseRecordOptions<TRecord extends RecordValue = RecordValue> = {
record?: TRecord | null
}
/** Reads a record override first, then falls back to the nearest context. */
export function useRecord<TRecord extends RecordValue = RecordValue>(
options?: UseRecordOptions<TRecord>
): TRecord | undefined {
const context = useContext(RecordContext) as TRecord | undefined
return options?.record ?? context
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/record": ["./src/index.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}