Skip to content

enhance: Hoist ImmutableJS vs Pojo split to import-time #3468

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 5, 2025
Merged
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
28 changes: 28 additions & 0 deletions .changeset/big-impalas-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@data-client/normalizr': patch
---

Add /imm exports path for handling ImmutableJS state

#### MemoCache

```ts
import { MemoCache } from '@data-client/normalizr';
import { MemoPolicy } from '@data-client/normalizr/imm';

const memo = new MemoCache(MemoPolicy);

// entities is an ImmutableJS Map
const value = MemoCache.denormalize(Todo, '1', entities);
```

#### denormalize

non-memoized denormalize

```ts
import { denormalize } from '@data-client/normalizr/imm';

// entities is an ImmutableJS Map
const value = denormalize(Todo, '1', entities);
```
24 changes: 24 additions & 0 deletions .changeset/legal-files-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@data-client/normalizr': minor
---

BREAKING: denormalize no longer detects ImmutableJS state

Use `/imm` exports to handle ImmutableJS state

#### Before

```ts
import { MemoCache, denormalize } from '@data-client/normalizr';

const memo = new MemoCache();
```

#### After

```ts
import { MemoCache } from '@data-client/normalizr';
import { MemoPolicy, denormalize } from '@data-client/normalizr/imm';

const memo = new MemoCache(MemoPolicy);
```
8 changes: 8 additions & 0 deletions .changeset/proud-taxes-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@data-client/normalizr': minor
'@data-client/endpoint': minor
---

delegate.getEntity(this.key) -> delegate.getEntities(this.key)

This applies to both schema.queryKey and schema.normalize method delegates.
9 changes: 9 additions & 0 deletions .changeset/ten-lions-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@data-client/normalizr': patch
'@data-client/core': patch
'@data-client/react': patch
---

Improve performance of get/denormalize for small responses

- 10-20% performance improvement due to removing immutablejs check for every call
4 changes: 1 addition & 3 deletions packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,8 +544,6 @@ export default class Controller<
};
}

// second argument is false if any entities are missing

const { data, paths } = this.memo.denormalize(
schema,
input,
Expand Down Expand Up @@ -652,7 +650,7 @@ function entityExpiresAt(
},
) {
let expiresAt = Infinity;
for (const { pk, key } of paths) {
for (const { key, pk } of paths) {
const entityExpiry = entitiesMeta[key]?.[pk]?.expiresAt;
// expiresAt will always resolve to false with any comparison
if (entityExpiry < expiresAt) expiresAt = entityExpiry;
Expand Down
20 changes: 11 additions & 9 deletions packages/core/src/state/GCPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ export class GCPolicy implements GCInterface {
if (key)
this.endpointCount.set(key, (this.endpointCount.get(key) ?? 0) + 1);
paths.forEach(path => {
if (!this.entityCount.has(path.key)) {
this.entityCount.set(path.key, new Map<string, number>());
const { key, pk } = path;
if (!this.entityCount.has(key)) {
this.entityCount.set(key, new Map<string, number>());
}
const instanceCount = this.entityCount.get(path.key)!;
instanceCount.set(path.pk, (instanceCount.get(path.pk) ?? 0) + 1);
const instanceCount = this.entityCount.get(key)!;
instanceCount.set(pk, (instanceCount.get(pk) ?? 0) + 1);
});

// decrement
Expand All @@ -68,18 +69,19 @@ export class GCPolicy implements GCInterface {
}
}
paths.forEach(path => {
if (!this.entityCount.has(path.key)) {
const { key, pk } = path;
if (!this.entityCount.has(key)) {
return;
}
const instanceCount = this.entityCount.get(path.key)!;
const entityCount = instanceCount.get(path.pk)!;
const instanceCount = this.entityCount.get(key)!;
const entityCount = instanceCount.get(pk)!;
if (entityCount !== undefined) {
if (entityCount <= 1) {
instanceCount.delete(path.pk);
instanceCount.delete(pk);
// queue for cleanup
this.entitiesQ.push(path);
} else {
instanceCount.set(path.pk, entityCount - 1);
instanceCount.set(pk, entityCount - 1);
}
}
});
Expand Down
16 changes: 1 addition & 15 deletions packages/endpoint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,7 @@ export { default as Entity } from './schemas/Entity.js';
export { default as EntityMixin } from './schemas/EntityMixin.js';
export type { IEntityClass, IEntityInstance } from './schemas/EntityTypes.js';
export { default as validateRequired } from './validateRequired.js';
export type {
EndpointInterface,
ReadEndpoint,
MutateEndpoint,
Schema,
IQueryDelegate,
INormalizeDelegate,
SnapshotInterface,
ExpiryStatusInterface,
SchemaSimple,
SchemaClass,
PolymorphicInterface,
Queryable,
Mergeable,
} from './interface.js';
export * from './interface.js';
export type { EntityFields } from './schemas/EntityFields.js';
export type {
AbstractInstanceType,
Expand Down
23 changes: 19 additions & 4 deletions packages/endpoint/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,24 +113,37 @@ export interface Visit {
creating?: boolean;
}

/** Used in denormalize. Lookup to find an entity in the store table */
export interface EntityPath {
key: string;
pk: string;
}

export type IndexPath = [key: string, index: string, value: string];
export type EntitiesPath = [key: string];

/** Returns true if a circular reference is found */
export interface CheckLoop {
(entityKey: string, pk: string, input: object): boolean;
}

/** Get Array of entities with map function applied */
/** Get all normalized entities of one type from store */
export interface GetEntities {
(key: string): { readonly [pk: string]: any } | undefined;
}
/** Get normalized Entity from store */
export interface GetEntity {
(entityKey: string | symbol): { readonly [pk: string]: any } | undefined;
(entityKey: string | symbol, pk: string | number): any;
(key: string, pk: string): any;
}
/** Get PK using an Entity Index */
export interface GetIndex {
/** getIndex('User', 'username', 'ntucker') */
(entityKey: string, field: string, value: string): string | undefined;
(...path: IndexPath): string | undefined;
}

/** Accessors to the currently processing state while building query */
export interface IQueryDelegate {
getEntities: GetEntities;
getEntity: GetEntity;
getIndex: GetIndex;
/** Return to consider results invalid */
Expand All @@ -141,6 +154,8 @@ export interface IQueryDelegate {
export interface INormalizeDelegate {
/** Action meta-data for this normalize call */
readonly meta: { fetchedAt: number; date: number; expiresAt: number };
/** Get all normalized entities of one type from store */
getEntities: GetEntities;
/** Gets any previously normalized entity from store */
getEntity: GetEntity;
/** Updates an entity using merge lifecycles when it has previously been set */
Expand Down
4 changes: 2 additions & 2 deletions packages/endpoint/src/schemas/All.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default class AllSchema<

queryKey(args: any, unvisit: any, delegate: IQueryDelegate): any {
if (this.isSingleSchema) {
const entitiesEntry = delegate.getEntity(this.schema.key);
const entitiesEntry = delegate.getEntities(this.schema.key);
// we must wait until there are entries for any 'All' query to be Valid
if (entitiesEntry === undefined) return delegate.INVALID;
return Object.values(entitiesEntry).map(
Expand All @@ -36,7 +36,7 @@ export default class AllSchema<
let found = false;
const list = Object.values(this.schema as Record<string, any>).flatMap(
(schema: EntityInterface) => {
const entitiesEntry = delegate.getEntity(schema.key);
const entitiesEntry = delegate.getEntities(schema.key);
if (entitiesEntry === undefined) return [];
found = true;
return Object.entries(entitiesEntry).map(([key, entity]) => ({
Expand Down
6 changes: 3 additions & 3 deletions packages/endpoint/src/schemas/Collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,9 @@ export default class CollectionSchema<

queryKey(args: Args, unvisit: unknown, delegate: IQueryDelegate): any {
if (this.argsKey) {
const id = this.pk(undefined, undefined, '', args);
const pk = this.pk(undefined, undefined, '', args);
// ensure this actually has entity or we shouldn't try to use it in our query
if (delegate.getEntity(this.key, id)) return id;
if (delegate.getEntity(this.key, pk)) return pk;
}
}

Expand Down Expand Up @@ -326,7 +326,7 @@ function normalizeCreate(
// parent is args when not nested
const filterCollections = (this.createCollectionFilter as any)(...args);
// add to any collections that match this
const entities = delegate.getEntity(this.key);
const entities = delegate.getEntities(this.key);
if (entities)
Object.keys(entities).forEach(collectionPk => {
if (!filterCollections(JSON.parse(collectionPk))) return;
Expand Down
12 changes: 6 additions & 6 deletions packages/endpoint/src/schemas/EntityMixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,9 @@ export default function EntityMixin<TBase extends Constructor>(
delegate: IQueryDelegate,
): any {
if (!args[0]) return;
const id = queryKeyCandidate(this, args, delegate);
const pk = queryKeyCandidate(this, args, delegate);
// ensure this actually has entity or we shouldn't try to use it in our query
if (id && delegate.getEntity(this.key, id)) return id;
if (pk && delegate.getEntity(this.key, pk)) return pk;
}

static denormalize<T extends typeof EntityMixin>(
Expand Down Expand Up @@ -484,8 +484,8 @@ function queryKeyCandidate(
// Was able to infer the entity's primary key from params
if (id !== undefined && id !== '') return id;
// now attempt lookup in indexes
const indexName = indexFromParams(args[0], schema.indexes);
if (!indexName) return;
const value = (args[0] as Record<string, any>)[indexName];
return delegate.getIndex(schema.key, indexName, value);
const field = indexFromParams(args[0], schema.indexes);
if (!field) return;
const value = (args[0] as Record<string, any>)[field];
return delegate.getIndex(schema.key, field, value);
}
35 changes: 25 additions & 10 deletions packages/endpoint/src/schemas/__tests__/All.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { initialState, State, Controller } from '@data-client/core';
import {
normalize,
MemoCache,
denormalize,
denormalize as plainDenormalize,
INVALID,
MemoPolicy as PojoDelegate,
} from '@data-client/normalizr';
import {
MemoPolicy as ImmDelegate,
denormalize as immDenormalize,
} from '@data-client/normalizr/imm';
import { IDEntity } from '__tests__/new';

import { schema } from '../..';
Expand Down Expand Up @@ -101,15 +106,23 @@ describe.each([[]])(`${schema.All.name} normalization (%s)`, () => {
});

describe.each([
['direct', <T>(data: T) => data, <T>(data: T) => data],
[
'direct',
<T>(data: T) => data,
<T>(data: T) => data,
PojoDelegate,
plainDenormalize,
],
[
'immutable',
fromJSState,
(v: any) => (typeof v?.toJS === 'function' ? v.toJS() : v),
ImmDelegate,
immDenormalize,
],
])(
`${schema.Array.name} denormalization (%s)`,
(_, createInput, createOutput) => {
(_, createInput, createOutput, MyDelegate, denormalize) => {
test('denormalizes a single entity', () => {
class Cat extends IDEntity {}
const state: State<unknown> = createInput({
Expand All @@ -123,7 +136,9 @@ describe.each([
indexes: {},
}) as any;
const sch = new schema.All(Cat);
expect(new Controller().get(sch, state)).toMatchSnapshot();
expect(
new Controller({ memo: new MemoCache(MyDelegate) }).get(sch, state),
).toMatchSnapshot();
});

test('denormalizes nested in object', () => {
Expand All @@ -140,7 +155,7 @@ describe.each([
});
// use memocache because we don't support 'object' schemas in controller yet
expect(
new MemoCache().query(catSchema, [], state).data,
new MemoCache(MyDelegate).query(catSchema, [], state).data,
).toMatchSnapshot();
});

Expand All @@ -156,7 +171,7 @@ describe.each([
},
indexes: {},
});
const value = new MemoCache().query(catSchema, [], state).data;
const value = new MemoCache(MyDelegate).query(catSchema, [], state).data;
expect(value).not.toEqual(expect.any(Symbol));
if (typeof value === 'symbol' || value === undefined) return;
expect(createOutput(value.results)).toMatchSnapshot();
Expand All @@ -177,7 +192,7 @@ describe.each([
},
indexes: {},
});
const value = new MemoCache().query(catSchema, [], state).data;
const value = new MemoCache(MyDelegate).query(catSchema, [], state).data;
expect(value).not.toEqual(expect.any(Symbol));
if (typeof value === 'symbol' || value === undefined) return;
expect(createOutput(value.results).length).toBe(2);
Expand Down Expand Up @@ -244,7 +259,7 @@ describe.each([
},
indexes: {},
});
const value = new MemoCache().query(catSchema, [], state).data;
const value = new MemoCache(MyDelegate).query(catSchema, [], state).data;
expect(createOutput(value)).toEqual(expect.any(Symbol));
});

Expand Down Expand Up @@ -276,7 +291,7 @@ describe.each([
},
indexes: {},
});
const value = new MemoCache().query(listSchema, [], state).data;
const value = new MemoCache(MyDelegate).query(listSchema, [], state).data;
expect(createOutput(value)).toEqual(expect.any(Symbol));
});

Expand Down Expand Up @@ -339,7 +354,7 @@ describe.each([
},
indexes: {},
});
const value = new MemoCache().query(listSchema, [], state).data;
const value = new MemoCache(MyDelegate).query(listSchema, [], state).data;
expect(value).not.toEqual(expect.any(Symbol));
if (typeof value === 'symbol') return;
expect(value).toMatchSnapshot();
Expand Down
Loading