diff --git a/examples/benchmark/core.js b/examples/benchmark/core.js index 9a895c78c977..838dd28618f7 100644 --- a/examples/benchmark/core.js +++ b/examples/benchmark/core.js @@ -6,6 +6,7 @@ import { Endpoint, Entity, normalize, + WeakEntityMap, } from './dist/index.js'; import { printStatus } from './printStatus.js'; import { @@ -113,6 +114,9 @@ export default function addReducerSuite(suite) { controller.globalCache = { entities: {}, results: {}, + queries: new Map(), + inputEndpointCache: {}, + infer: new WeakEntityMap(), }; return controller.getResponse(getProject, cachedState); }) diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index 2d728b648e3d..d6760c0c5085 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -1,9 +1,11 @@ import type { ErrorTypes, SnapshotInterface, - DenormalizeCache, Schema, Denormalize, + Queryable, + SchemaArgs, + ResultCache, } from '@data-client/normalizr'; import { WeakEntityMap, @@ -33,7 +35,7 @@ import ensurePojo from './ensurePojo.js'; import type { EndpointUpdateFunction } from './types.js'; import { initialState } from '../state/reducer/createReducer.js'; import selectMeta from '../state/selectMeta.js'; -import type { ActionTypes, State } from '../types.js'; +import type { ActionTypes, State, DenormalizeCache } from '../types.js'; export type GenericDispatch = (value: any) => Promise; export type DataClientDispatch = (value: ActionTypes) => Promise; @@ -85,6 +87,9 @@ export default class Controller< globalCache = { entities: {}, results: {}, + queries: new Map(), + inputEndpointCache: {}, + infer: new WeakEntityMap(), }, }: ConstructorProps = {}) { this.dispatch = dispatch; @@ -335,21 +340,42 @@ export default class Controller< * Gets the (globally referentially stable) response for a given endpoint/args pair from state given. * @see https://dataclient.io/docs/api/Controller#getResponse */ - getResponse = < + getResponse( + endpoint: E, + ...rest: readonly [null, State] + ): { + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; + }; + + getResponse( + endpoint: E, + ...rest: readonly [...Parameters, State] + ): { + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; + }; + + getResponse< E extends Pick, - Args extends readonly [...Parameters] | readonly [null], >( endpoint: E, - ...rest: [...Args, State] + ...rest: readonly [ + ...(readonly [...Parameters] | readonly [null]), + State, + ] ): { data: DenormalizeNullable; expiryStatus: ExpiryStatus; expiresAt: number; - } => { + } { const state = rest[rest.length - 1] as State; // this is typescript generics breaking const args: any = rest .slice(0, rest.length - 1) + // handle FormData .map(ensurePojo) as Parameters; const isActive = args.length !== 1 || args[0] !== null; const key = isActive ? endpoint.key(...args) : ''; @@ -360,17 +386,25 @@ export default class Controller< let invalidResults = false; let results; + let resultCache: ResultCache; if (cacheResults === undefined && endpoint.schema !== undefined) { - results = inferResults( - endpoint.schema, - args, - state.indexes, - state.entities, - ); + if (!this.globalCache.inputEndpointCache[key]) + this.globalCache.inputEndpointCache[key] = inferResults( + endpoint.schema, + args, + state.indexes, + state.entities, + ); + results = this.globalCache.inputEndpointCache[key]; + invalidResults = !validateInference(results); if (!expiresAt && invalidResults) expiresAt = 1; + resultCache = this.globalCache.infer; } else { results = cacheResults; + if (!this.globalCache.results[key]) + this.globalCache.results[key] = new WeakEntityMap(); + resultCache = this.globalCache.results[key]; } if (!isActive) { @@ -396,19 +430,89 @@ export default class Controller< }; } - if (!this.globalCache.results[key]) - this.globalCache.results[key] = new WeakEntityMap(); + return this.getSchemaResponse( + results, + schema as Exclude, + args, + state, + expiresAt, + resultCache, + endpoint.invalidIfStale || invalidResults, + meta, + ); + } + /** + * Queries the store for a Querable schema + * @see https://dataclient.io/docs/api/Controller#query + */ + query( + schema: S, + ...rest: readonly [ + ...SchemaArgs, + Pick, 'entities' | 'entityMeta'>, + ] + ): DenormalizeNullable | undefined { + const state = rest[rest.length - 1] as State; + // this is typescript generics breaking + const args: any = rest + .slice(0, rest.length - 1) + .map(ensurePojo) as SchemaArgs; + + // MEMOIZE inferResults - vary on schema + args + // NOTE: different orders can result in cache busting here; but since it's just a perf penalty we will allow for now + const key = JSON.stringify(args); + if (!this.globalCache.queries.has(schema)) { + this.globalCache.queries.set(schema, {}); + } + const querySchemaCache = this.globalCache.queries.get(schema) as { + [key: string]: unknown; + }; + if (!querySchemaCache[key]) + querySchemaCache[key] = inferResults( + schema, + args, + state.indexes, + state.entities, + ); + const results = querySchemaCache[key]; + // END BLOCK + + const data = denormalizeCached( + results, + schema, + state.entities, + this.globalCache.entities, + this.globalCache.infer, + args, + ).data; + return typeof data === 'symbol' ? undefined : (data as any); + } + + private getSchemaResponse( + input: any, + schema: S, + args: any, + state: State, + expiresAt: number, + resultCache: ResultCache, + invalidIfStale: boolean, + meta: { error?: unknown; invalidated?: unknown } = {}, + ): { + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; + } { // second argument is false if any entities are missing // eslint-disable-next-line prefer-const const { data, paths } = denormalizeCached( - results, + input, schema, state.entities, this.globalCache.entities, - this.globalCache.results[key], + resultCache, args, - ) as { data: DenormalizeNullable; paths: Path[] }; + ) as { data: DenormalizeNullable; paths: Path[] }; const invalidDenormalize = typeof data === 'symbol'; // fallback to entity expiry time @@ -422,12 +526,11 @@ export default class Controller< const expiryStatus = meta?.invalidated || (invalidDenormalize && !meta?.error) ? ExpiryStatus.Invalid - : invalidDenormalize || endpoint.invalidIfStale || invalidResults ? - ExpiryStatus.InvalidIfStale + : invalidDenormalize || invalidIfStale ? ExpiryStatus.InvalidIfStale : ExpiryStatus.Valid; return { data, expiryStatus, expiresAt }; - }; + } } // benchmark: https://www.measurethat.net/Benchmarks/Show/24691/0/min-reducer-vs-imperative-with-paths @@ -487,19 +590,38 @@ class Snapshot implements SnapshotInterface { /*************** Data Access ***************/ /** @see https://dataclient.io/docs/api/Snapshot#getResponse */ - getResponse = < + getResponse( + endpoint: E, + ...args: readonly [null] + ): { + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; + }; + + getResponse( + endpoint: E, + ...args: readonly [...Parameters] + ): { + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; + }; + + getResponse< E extends Pick, - Args extends readonly [...Parameters], >( endpoint: E, - ...args: Args + ...args: readonly [...Parameters] | readonly [null] ): { data: DenormalizeNullable; expiryStatus: ExpiryStatus; expiresAt: number; - } => { + } { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore return this.controller.getResponse(endpoint, ...args, this.state); - }; + } /** @see https://dataclient.io/docs/api/Snapshot#getError */ getError = < @@ -511,4 +633,15 @@ class Snapshot implements SnapshotInterface { ): ErrorTypes | undefined => { return this.controller.getError(endpoint, ...args, this.state); }; + + /** + * Queries the store for a Querable schema + * @see https://dataclient.io/docs/api/Snapshot#query + */ + query( + schema: S, + ...args: SchemaArgs + ): DenormalizeNullable | undefined { + return this.controller.query(schema, ...args, this.state); + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 768e9b2b0a09..e968716bdcc1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,13 +13,16 @@ export type { EndpointInterface, EntityInterface, ResolveType, - DenormalizeCache, + EntityCache, + ResultCache, DenormalizeNullable, Denormalize, Normalize, NormalizeNullable, FetchFunction, EndpointExtraOptions, + Queryable, + SchemaArgs, } from '@data-client/normalizr'; export { ExpiryStatus } from '@data-client/normalizr'; export { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 864af6db3fb2..c5199a012a2b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2,6 +2,9 @@ import { NormalizedIndex } from '@data-client/normalizr'; import type { UpdateFunction, AbstractInstanceType, + EntityCache, + ResultCache, + Queryable, } from '@data-client/normalizr'; import type { ErrorTypes } from '@data-client/normalizr'; @@ -44,6 +47,24 @@ export interface State { readonly lastReset: number; } +export interface DenormalizeCache { + entities: EntityCache; + results: { + [key: string]: ResultCache; + }; + infer: ResultCache; + + inputEndpointCache: { + [key: string]: unknown; + }; + queries: Map< + Queryable, + { + [key: string]: unknown; + } + >; +} + export * from './actions.js'; export interface Manager { diff --git a/packages/endpoint/src/queryEndpoint.ts b/packages/endpoint/src/queryEndpoint.ts index dc70affae1f4..e08c6bc4a14e 100644 --- a/packages/endpoint/src/queryEndpoint.ts +++ b/packages/endpoint/src/queryEndpoint.ts @@ -1,6 +1,7 @@ import type { EntityTable, NormalizedIndex, + Queryable, SchemaSimple, } from './interface.js'; import type { Denormalize, SchemaArgs } from './normal.js'; @@ -10,7 +11,7 @@ import type { Denormalize, SchemaArgs } from './normal.js'; * @see https://dataclient.io/rest/api/Query */ export class Query< - S extends SchemaSimple, + S extends Queryable, P extends SchemaArgs = SchemaArgs, R = Denormalize, > { @@ -30,7 +31,7 @@ export class Query< return `QUERY ${JSON.stringify(args)}`; } - protected createQuerySchema(schema: SchemaSimple) { + protected createQuerySchema(schema: S) { const query = Object.create(schema); query.denormalize = (input: any, args: P, unvisit: any) => { const value = unvisit(input, schema); @@ -44,7 +45,7 @@ export class Query< args: any, indexes: any, recurse: ( - schema: SchemaSimple, + schema: any, args: any[], indexes: NormalizedIndex, entities: EntityTable, diff --git a/packages/endpoint/src/schemas/__tests__/denormalize.ts b/packages/endpoint/src/schemas/__tests__/denormalize.ts index 91dc0a48067c..32750160027e 100644 --- a/packages/endpoint/src/schemas/__tests__/denormalize.ts +++ b/packages/endpoint/src/schemas/__tests__/denormalize.ts @@ -1,19 +1,19 @@ import { denormalizeCached as denormalizeCore, Schema, - DenormalizeCache, + EntityCache, + ResultCache, WeakEntityMap, Denormalize, DenormalizeNullable, - INVALID, } from '@data-client/normalizr'; export const denormalizeSimple = ( input: any, schema: S | undefined, entities: any, - entityCache: DenormalizeCache['entities'] = {}, - resultCache: DenormalizeCache['results'][string] = new WeakEntityMap(), + entityCache: EntityCache = {}, + resultCache: ResultCache = new WeakEntityMap(), args: any[] = [], ): Denormalize | DenormalizeNullable | symbol => denormalizeCore(input, schema, entities, entityCache, resultCache, args) diff --git a/packages/normalizr/src/denormalize/denormalizeCached.ts b/packages/normalizr/src/denormalize/denormalizeCached.ts index cb51f04cf805..dca34a1d9cd2 100644 --- a/packages/normalizr/src/denormalize/denormalizeCached.ts +++ b/packages/normalizr/src/denormalize/denormalizeCached.ts @@ -1,7 +1,12 @@ import GlobalCache from './globalCache.js'; import getUnvisit from './unvisit.js'; import type { Schema } from '../interface.js'; -import type { DenormalizeNullable, DenormalizeCache, Path } from '../types.js'; +import type { + DenormalizeNullable, + EntityCache, + Path, + ResultCache, +} from '../types.js'; import WeakEntityMap, { getEntities } from '../WeakEntityMap.js'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -9,8 +14,8 @@ export function denormalize( input: unknown, schema: S | undefined, entities: any, - entityCache: DenormalizeCache['entities'] = {}, - resultCache: DenormalizeCache['results'][string] = new WeakEntityMap(), + entityCache: EntityCache = {}, + resultCache: ResultCache = new WeakEntityMap(), args: readonly any[] = [], ): { data: DenormalizeNullable | symbol; diff --git a/packages/normalizr/src/denormalize/globalCache.ts b/packages/normalizr/src/denormalize/globalCache.ts index d832deb073c7..51539d6b0acf 100644 --- a/packages/normalizr/src/denormalize/globalCache.ts +++ b/packages/normalizr/src/denormalize/globalCache.ts @@ -1,6 +1,6 @@ import type Cache from './cache.js'; import type { EntityInterface } from '../interface.js'; -import type { DenormalizeCache, Path } from '../types.js'; +import type { EntityCache, Path, ResultCache } from '../types.js'; import WeakEntityMap, { type Dep, type GetEntity, @@ -19,12 +19,12 @@ export default class GlobalCache implements Cache { ) => WeakEntityMap; private declare _getEntity: GetEntity; - private declare resultCache: DenormalizeCache['results'][string]; + private declare resultCache: ResultCache; constructor( getEntity: GetEntity, - entityCache: DenormalizeCache['entities'], - resultCache: DenormalizeCache['results'][string], + entityCache: EntityCache, + resultCache: ResultCache, ) { this._getEntity = getEntity; this.getCache = getEntityCaches(entityCache); @@ -104,6 +104,7 @@ export default class GlobalCache implements Cache { return { localCacheKey, cycleCacheKey }; } + /** Cache varies based on input (=== aka reference) */ getResults( input: any, cachable: boolean, @@ -140,7 +141,7 @@ interface EntityCacheValue { value: object | symbol | undefined; } -const getEntityCaches = (entityCache: DenormalizeCache['entities']) => { +const getEntityCaches = (entityCache: EntityCache) => { return (pk: string, schema: EntityInterface) => { const key = schema.key; // collections should use the entities they collect over diff --git a/packages/normalizr/src/index.ts b/packages/normalizr/src/index.ts index 0d7beff98010..5251b7fae379 100644 --- a/packages/normalizr/src/index.ts +++ b/packages/normalizr/src/index.ts @@ -14,7 +14,8 @@ export type { AbstractInstanceType, NormalizeReturnType, NormalizedSchema, - DenormalizeCache, + EntityCache, + ResultCache, Path, Denormalize, DenormalizeNullable, diff --git a/packages/normalizr/src/types.ts b/packages/normalizr/src/types.ts index e9b31e5fc786..a65185e0b4cf 100644 --- a/packages/normalizr/src/types.ts +++ b/packages/normalizr/src/types.ts @@ -54,16 +54,12 @@ export interface RecordClass extends NestedSchemaClass { fromJS: (...args: any) => AbstractInstanceType; } -export interface DenormalizeCache { - entities: { - [key: string]: { - [pk: string]: WeakMap>; - }; - }; - results: { - [key: string]: WeakEntityMap; +export interface EntityCache { + [key: string]: { + [pk: string]: WeakMap>; }; } +export type ResultCache = WeakEntityMap; export type DenormalizeNullableNestedSchema = keyof S['schema'] extends never ? diff --git a/packages/react/src/context.ts b/packages/react/src/context.ts index d71681dda4cd..a90a6739dd8c 100644 --- a/packages/react/src/context.ts +++ b/packages/react/src/context.ts @@ -25,10 +25,6 @@ const dispatch = (value: ActionTypes) => { export const ControllerContext = createContext( new Controller({ dispatch, - globalCache: { - entities: {}, - results: {}, - }, }), ); diff --git a/packages/react/src/hooks/useCache.ts b/packages/react/src/hooks/useCache.ts index 449c38145608..42dbc0329fde 100644 --- a/packages/react/src/hooks/useCache.ts +++ b/packages/react/src/hooks/useCache.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { +import type { EndpointInterface, DenormalizeNullable, Schema, @@ -23,10 +23,9 @@ export default function useCache< EndpointInterface, 'key' | 'schema' | 'invalidIfStale' >, - Args extends readonly [...Parameters] | readonly [null], >( endpoint: E, - ...args: Args + ...args: readonly [...Parameters] | readonly [null] ): E['schema'] extends undefined | null ? E extends (...args: any) => any ? ResolveType | undefined @@ -84,7 +83,8 @@ export default function useCache< } return data; // key substitutes args + endpoint + // we only need cacheResults, as entities are not used in this case // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, controller, data, wouldSuspend, state]); + }, [key, controller, data, wouldSuspend, cacheResults]); /*********************** end block *****************************/ } diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts new file mode 100644 index 000000000000..97d467dd4ab3 --- /dev/null +++ b/packages/react/src/hooks/useQuery.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { + DenormalizeNullable, + Queryable, + SchemaArgs, +} from '@data-client/core'; +import { useMemo } from 'react'; + +import useCacheState from './useCacheState.js'; +import useController from '../hooks/useController.js'; + +/** + * Query the store. + * + * `useQuery` results are globally memoized. + * @see https://dataclient.io/docs/api/useQuery + */ +export function useQuery( + schema: S, + ...args: SchemaArgs +): DenormalizeNullable | undefined { + const state = useCacheState(); + const controller = useController(); + + const key = JSON.stringify(args); + + // even though controller.query() is memoized, its memoization is more complex than + // this so we layer it up to improve rerenders + return useMemo(() => { + return controller.query(schema, ...args, state); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.indexes, state.entities, key]); +}