Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/imm-denormalize-args.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@data-client/normalizr': patch
---

Fix `denormalize` from `@data-client/normalizr/imm` ignoring `args`

Args-dependent schemas (like [Scalar](https://dataclient.io/rest/api/Scalar)) resolved to `undefined`
when denormalized directly through the `/imm` entry point, because the endpoint
args were not threaded through. They now denormalize correctly, matching the
main entry point's behavior.
54 changes: 54 additions & 0 deletions .changeset/immutable-main-entry-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@data-client/normalizr': minor
'@data-client/endpoint': minor
'@data-client/rest': minor
'@data-client/graphql': minor
'@data-client/react': minor
'@data-client/vue': minor
---

BREAKING CHANGE: ImmutableJS input is no longer supported by the default (main-entry) denormalize

Denormalizing normalized data that contains ImmutableJS `Map`/`Record` values
now requires the `/imm` entry points. The default `denormalize` and
`MemoCache` assume plain-object values, removing a per-object check from the
hot path and all ImmutableJS support code from the main bundles. In
development, passing immutable input to the default denormalize now throws a
descriptive error instead of silently producing corrupt output.

ImmutableJS v4 or later is required: detection of legacy v3 Records (which
kept their values on an internal `_map`) has been removed.

Immutable *state tables* already required `@data-client/normalizr/imm` since
v0.15 — that usage is unchanged and continues to support immutable results:

#### Before

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

denormalize({ data: Article }, fromJS({ data: '1' }), entities);
```

#### After

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

denormalize({ data: Article }, fromJS({ data: '1' }), entities);
```

Alternatively, convert values to plain objects before denormalizing with the
main entry.

For custom schemas: `Polymorphic.denormalizeValue(value, unvisit)` is now
`denormalizeValue(value, delegate)` — pass the delegate your `denormalize`
receives instead of `delegate.unvisit`.

New exports:

- `PlainValuePolicy` from `@data-client/normalizr` — default plain-object value handling
- `ImmValuePolicy` from `@data-client/normalizr/imm` — ImmutableJS-aware value handling
- `IValuePolicy` (type) — implement to customize value handling in a `MemoCache` policy
25 changes: 25 additions & 0 deletions .github/workflows/bundle_size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,28 @@ jobs:
pattern: "examples/test-bundlesize/dist/**/*.{js,json}"
exclude: "{examples/test-bundlesize/dist/manifest.json,**/*.LICENSE.txt,**/*.map,**/node_modules/**}"
minimum-change-threshold: 8

immutable-leak:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'yarn'
- name: Install packages
run: ./scripts/ci-install.sh examples/test-bundlesize
- name: Build production bundles
run: yarn ci:build:bundlesize
- name: Assert no ImmutableJS code in main-entry bundles
# Immutable support must stay behind /imm subpath exports (zero bundle
# cost for non-users — see plans/immutablejs-support.md F1). The
# `__ownerID` string only appears in the ImmutableJS duck-type check,
# so its presence means immutable code leaked into a main entry.
run: |
if grep -rl --include='*.js' "__ownerID" examples/test-bundlesize/dist/; then
echo "::error::ImmutableJS support code leaked into main-entry bundles (__ownerID marker found). Keep immutable code behind /imm subpath exports."
exit 1
fi
echo "No immutable markers found in production bundles."
105 changes: 104 additions & 1 deletion examples/benchmark/micro.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { WeakDependencyMap } from './dist/index.js';
import {
WeakDependencyMap,
denormalize,
Entity,
MemoCache,
schema,
} from './dist/index.js';
import { createAdd } from './filter.js';
import { printStatus } from './printStatus.js';

Expand All @@ -13,6 +19,7 @@ import { printStatus } from './printStatus.js';
* 5. slice+map vs pre-allocated extraction
* 6. Map double-get vs single-get
* 7. WeakDependencyMap get/set (entity vs argsKey/scalar paths)
* 8. Object/Polymorphic denormalize (isImmutable hot-path cost)
*
* @param {import('benchmark').Suite} suite
* @param {string} [filter]
Expand Down Expand Up @@ -374,6 +381,102 @@ export default function addMicroSuite(suite, filter) {
}
});

// ============================================================
// Optimization 8: Object/Polymorphic denormalize hot path
// ============================================================
// Isolates the per-node isImmutable check in object-schema denormalize
// (normalizr schemas/Object.ts, endpoint schemas/Object.ts) and the
// per-item discriminator reads in Polymorphic.denormalizeValue
// (endpoint schemas/Polymorphic.ts). Baseline for evicting immutable
// branches from main-entry denormalize (plans/immutablejs-support.md F1).

// depth-2 fanout-5 tree: 31 object-schema nodes visited per call
function buildObjTree(depth, useClass) {
if (depth === 0) {
return [useClass ? new schema.Object({}) : {}, { v0: 0, v1: 1, v2: 2 }];
}
const schemaNode = {};
const inputNode = {};
for (let i = 0; i < 5; i++) {
const [s, inp] = buildObjTree(depth - 1, useClass);
schemaNode[`k${i}`] = s;
inputNode[`k${i}`] = inp;
}
return [useClass ? new schema.Object(schemaNode) : schemaNode, inputNode];
}
const [shorthandTreeSchema, shorthandTreeInput] = buildObjTree(2, false);
const [classTreeSchema, classTreeInput] = buildObjTree(2, true);
const emptyEntities = {};

add('8-objectShorthand denormalize (31 nodes)', () => {
for (let i = 0; i < 100; i++) {
denormalize(shorthandTreeSchema, shorthandTreeInput, emptyEntities);
}
});

add('8-objectClass denormalize (31 nodes)', () => {
for (let i = 0; i < 100; i++) {
denormalize(classTreeSchema, classTreeInput, emptyEntities);
}
});

class MicroCat extends Entity {
id = '';
name = '';
static key = 'MicroCat';
}
class MicroDog extends Entity {
id = '';
name = '';
static key = 'MicroDog';
}
const PetUnion = new schema.Union({ cats: MicroCat, dogs: MicroDog }, 'type');
const unionEntities = { MicroCat: {}, MicroDog: {} };
for (let i = 0; i < 50; i++) {
unionEntities.MicroCat[i] = { id: `${i}`, name: `cat${i}`, type: 'cats' };
unionEntities.MicroDog[i] = { id: `${i}`, name: `dog${i}`, type: 'dogs' };
}
const unionList = [];
for (let i = 0; i < 100; i++) {
unionList.push({ id: `${i % 50}`, schema: i % 2 ? 'dogs' : 'cats' });
}

add('8-unionPolymorphic denormalize (100 items)', () => {
for (let i = 0; i < 10; i++) {
denormalize([PetUnion], unionList, unionEntities);
}
});

// Cached (MemoCache hit) counterparts: guard per-call overhead — delegate
// construction in getUnvisit runs before the result-cache short-circuit,
// so cache hits still pay it. Stable schema/input/entity refs → pure hits.
const unionListSchema = [PetUnion];
const objectMemo = new MemoCache();
const unionMemo = new MemoCache();
// prime the caches
objectMemo.denormalize(
shorthandTreeSchema,
shorthandTreeInput,
emptyEntities,
);
unionMemo.denormalize(unionListSchema, unionList, unionEntities);

add('8-objectShorthand denormalize withCache (31 nodes)', () => {
for (let i = 0; i < 100; i++) {
objectMemo.denormalize(
shorthandTreeSchema,
shorthandTreeInput,
emptyEntities,
);
}
});

add('8-unionPolymorphic denormalize withCache (100 items)', () => {
for (let i = 0; i < 100; i++) {
unionMemo.denormalize(unionListSchema, unionList, unionEntities);
}
});

// ============================================================
// Completion handler with V8 optimization status
// ============================================================
Expand Down
10 changes: 10 additions & 0 deletions packages/endpoint/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,16 @@ export interface IQueryDelegate {
export interface IDenormalizeDelegate {
/** Recursive denormalize of nested schemas */
unvisit(schema: any, input: any): any;
/** Denormalize an object-shaped schema node ({ key: Schema }).
*
* Value-representation aware (plain vs ImmutableJS input handling).
* Optional because delegates from older @data-client/normalizr versions
* lack it — callers must fall back to the plain (POJO) path. */
unvisitObject?(schema: Record<string, any>, input: any): any;
/** Read a field from a normalized reference (e.g. polymorphic
* `schema`/`id` discriminators), respecting the value representation.
* Optional for the same cross-version reason as `unvisitObject`. */
getField?(value: any, key: string): any;
/** Raw endpoint args. Reading this does NOT contribute to cache
* invalidation — if your output varies with args, register an `argsKey`
* so the cache buckets correctly. */
Expand Down
4 changes: 1 addition & 3 deletions packages/endpoint/src/schemas/Array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ export default class ArraySchema extends PolymorphicSchema {
denormalize(input: any, delegate: IDenormalizeDelegate) {
return input.map ?
input
.map((entityOrId: any) =>
this.denormalizeValue(entityOrId, delegate.unvisit),
)
.map((entityOrId: any) => this.denormalizeValue(entityOrId, delegate))
.filter(filterEmpty)
: input;
}
Expand Down
50 changes: 0 additions & 50 deletions packages/endpoint/src/schemas/ImmutableUtils.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/endpoint/src/schemas/Invalidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export default class Invalidate<
): E extends ProcessableEntity ? AbstractInstanceType<E>
: AbstractInstanceType<E[keyof E]> {
// denormalizeValue handles both single entity and polymorphic cases
return this.denormalizeValue(id, delegate.unvisit) as any;
return this.denormalizeValue(id, delegate) as any;
}

/* istanbul ignore next */
Expand Down
35 changes: 29 additions & 6 deletions packages/endpoint/src/schemas/Object.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isImmutable, denormalizeImmutable } from './ImmutableUtils.js';
import { IDenormalizeDelegate, Visit } from '../interface.js';

export const normalize = (
Expand Down Expand Up @@ -27,8 +26,30 @@ export function denormalize(
input: {},
delegate: IDenormalizeDelegate,
): any {
if (isImmutable(input)) {
return denormalizeImmutable(schema, input, delegate.unvisit);
if (delegate.unvisitObject) {
// value-representation aware path (plain or ImmutableJS input, decided
// by the active policy)
return delegate.unvisitObject(schema, input);
}
// Fallback plain (POJO) loop for delegates from older
// @data-client/normalizr versions, which lack unvisitObject.

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
// ImmutableJS Map/Record inputs are only supported via the /imm entries.
// Detect here (dev only — stripped from production) to fail loudly
// instead of silently producing corrupt output from spreading a Map.
if (
input &&
typeof (input as any).hasOwnProperty === 'function' &&
Object.hasOwnProperty.call(input, '__ownerID')
) {
throw new Error(
`Immutable input is not supported by the default denormalize.
Use @data-client/normalizr/imm entries (denormalize or MemoCache with its MemoPolicy) for ImmutableJS state.
See https://github.com/reactive/data-client/blob/master/packages/normalizr/README.md#immutablejs`,
);
}
}

const object: Record<string, any> = { ...input };
Expand All @@ -37,12 +58,14 @@ export function denormalize(
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const item = delegate.unvisit(schema[key], object[key]);
if (object[key] !== undefined) {
object[key] = item;
}
if (typeof item === 'symbol') {
// propagate the exact symbol so identity checks (INVALID) work across
// package boundaries
return item;
}
if (object[key] !== undefined) {
object[key] = item;
}
}
return object;
}
Expand Down
Loading