This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new bffa8f198 fix(javascript): honor declared element types for root
container registration (#4013)
bffa8f198 is described below
commit bffa8f198a734aa5ebd2420c044d7cb5d06ddbd7
Author: Ayush Kumar <[email protected]>
AuthorDate: Mon Sep 7 09:00:21 2026 +0530
fix(javascript): honor declared element types for root container
registration (#4013)
## What was the error
Registering a root container with declared element types, e.g.
`fory.register(Type.list(Type.float32()))`, silently returned the
internal any-typed container serializer. The declared generics were
discarded: elements were written with dynamic dispatch instead of the
declared type, so declared `float32`/`int64` semantics were lost at the
root while the same declaration worked as a struct field.
## What this PR fixes
A root `Type.list`/`Type.set`/`Type.map` with declared element types now
gets a dedicated generated serializer, bound to the returned root
serialize/deserialize pair. It is kept out of the type-id keyed registry
so dynamic container dispatch stays untouched. Regression tests cover
root list, set, and map plus the dynamic-dispatch guard.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: chaokunyang <[email protected]>
---
javascript/packages/core/lib/fory.ts | 17 +++++++----
javascript/packages/core/lib/gen/index.ts | 21 +++++++++----
javascript/packages/core/lib/gen/map.ts | 6 ++--
javascript/packages/core/lib/typeInfo.ts | 26 ++++++++++++++++
javascript/test/array.test.ts | 50 +++++++++++++++++++++++++++++++
javascript/test/map.test.ts | 40 ++++++++++++++++++++++++-
6 files changed, 146 insertions(+), 14 deletions(-)
diff --git a/javascript/packages/core/lib/fory.ts
b/javascript/packages/core/lib/fory.ts
index 8eff8df04..04cd1fd6e 100644
--- a/javascript/packages/core/lib/fory.ts
+++ b/javascript/packages/core/lib/fory.ts
@@ -27,7 +27,7 @@ import {
TypeId,
CustomSerializer,
} from "./type";
-import { InputType, ResultType, TypeInfo } from "./typeInfo";
+import { containerDeclaresElementTypes, InputType, ResultType, TypeInfo } from
"./typeInfo";
import { Gen } from "./gen";
import { PlatformBuffer } from "./platformBuffer";
import { ReadContext, WriteContext } from "./context";
@@ -163,7 +163,12 @@ export default class Fory {
serializer = new Gen(this.typeResolver, {
customSerializer,
}).generateSerializer(typeInfo);
- this.typeResolver.registerSerializer(typeInfo, serializer);
+ if (!containerDeclaresElementTypes(typeInfo)) {
+ // A declared-element container serializer is bound to this
+ // registration only; publishing it under the bare container type id
+ // would replace the dynamic container serializer.
+ this.typeResolver.registerSerializer(typeInfo, serializer);
+ }
}
return {
serializer,
@@ -223,9 +228,11 @@ export default class Fory {
}
const readContext = this.readContext;
const reader = readContext.reader;
- const rootSerializer = TypeId.polymorphicType(serializer.getTypeId())
- ? serializer
- : this.anySerializer;
+ const rootSerializer =
+ TypeId.polymorphicType(serializer.getTypeId()) ||
+ containerDeclaresElementTypes(serializer.getTypeInfo())
+ ? serializer
+ : this.anySerializer;
const rootHeader = ConfigFlags.isCrossLanguageFlag;
rootDeserializer = (bytes: Uint8Array) => {
readContext.reset(bytes);
diff --git a/javascript/packages/core/lib/gen/index.ts
b/javascript/packages/core/lib/gen/index.ts
index 9e6244672..9b9a4f841 100644
--- a/javascript/packages/core/lib/gen/index.ts
+++ b/javascript/packages/core/lib/gen/index.ts
@@ -18,7 +18,7 @@
*/
import { TypeId, Serializer } from "../type";
-import { TypeInfo } from "../typeInfo";
+import { containerDeclaresElementTypes, TypeInfo } from "../typeInfo";
import { CodegenRegistry } from "./router";
import { CodecBuilder } from "./builder";
import { Scope } from "./scope";
@@ -139,11 +139,14 @@ export class Gen {
this.traversalContainer(x);
});
this.register(typeInfo, this.generate(typeInfo));
- } else if (!this.isRegistered(typeInfo) &&
TypeId.structType(typeInfo.typeId)) {
- // Forward reference to a struct type not yet fully defined — register
a
- // placeholder so that serializer factories can capture the object
- // reference. The placeholder will be filled in via Object.assign
- // when the real serializer is generated later.
+ } else if (
+ !this.isRegistered(typeInfo) &&
+ (TypeId.structType(typeInfo.typeId) || TypeId.extType(typeInfo.typeId))
+ ) {
+ // Forward reference to a struct or ext type not yet fully defined —
+ // register a placeholder so that serializer factories can capture the
+ // object reference. The placeholder will be filled in via
+ // Object.assign when the real serializer is generated later.
this.register(typeInfo);
} else if (TypeId.enumType(typeInfo.typeId) &&
!this.isRegistered(typeInfo)) {
this.register(typeInfo, this.generate(typeInfo));
@@ -175,6 +178,12 @@ export class Gen {
generateSerializer(typeInfo: TypeInfo) {
this.traversalContainer(typeInfo);
+ if (containerDeclaresElementTypes(typeInfo)) {
+ // The type-id keyed registry only holds the dynamic container
+ // serializer; a container with declared element types gets a dedicated
+ // serializer for this registration.
+ return this.generate(typeInfo);
+ }
const serializer = this.typeResolver.getSerializerByTypeInfo(typeInfo);
if (serializer?._initialized) {
return serializer;
diff --git a/javascript/packages/core/lib/gen/map.ts
b/javascript/packages/core/lib/gen/map.ts
index d9fcdebf3..4624d4587 100644
--- a/javascript/packages/core/lib/gen/map.ts
+++ b/javascript/packages/core/lib/gen/map.ts
@@ -397,8 +397,10 @@ export class MapSerializerGenerator extends
BaseSerializerGenerator {
}
private useDeclaredType(typeInfo: TypeInfo) {
- const readWriteTypeInfo =
- this.builder.resolver.getSerializerByTypeInfo(typeInfo)?.getTypeInfo()
?? typeInfo;
+ const serializer = this.builder.resolver.getSerializerByTypeInfo(typeInfo);
+ // Forward registrations expose a placeholder whose metadata is
unavailable until the codec
+ // is registered. Keep the declared schema until that serializer is
initialized.
+ const readWriteTypeInfo = serializer?._initialized ?
serializer.getTypeInfo() : typeInfo;
// Evolving structs need per-chunk TypeInfo so a compatible reader can
discard a removed map
// field. A fixed-schema serializer deliberately keeps the declared form:
evolving=false is its
// same-schema size and speed opt-out, even when the field declaration is
only a placeholder.
diff --git a/javascript/packages/core/lib/typeInfo.ts
b/javascript/packages/core/lib/typeInfo.ts
index 95ff4a0a4..a92f438c5 100644
--- a/javascript/packages/core/lib/typeInfo.ts
+++ b/javascript/packages/core/lib/typeInfo.ts
@@ -26,6 +26,32 @@ import { Decimal } from "./types/decimal";
const targetFields = new WeakMap<new () => any, { [key: string]: TypeInfo }>();
export const MAX_FIELD_ID = (1 << 29) - 1;
+/**
+ * Whether this container TypeInfo declares concrete element types instead of
+ * dynamic `any` elements. Such a TypeInfo is a usage schema for one
+ * registration: it needs its own generated serializer and must not replace
+ * the dynamic container serializer in the type-id keyed registry.
+ */
+export function containerDeclaresElementTypes(typeInfo: TypeInfo): boolean {
+ if (typeInfo.typeId === TypeId.LIST) {
+ const inner = typeInfo.options?.inner;
+ return inner !== undefined && inner.typeId !== TypeId.UNKNOWN;
+ }
+ if (typeInfo.typeId === TypeId.SET) {
+ const inner = typeInfo.options?.key;
+ return inner !== undefined && inner.typeId !== TypeId.UNKNOWN;
+ }
+ if (typeInfo.typeId === TypeId.MAP) {
+ const key = typeInfo.options?.key;
+ const value = typeInfo.options?.value;
+ return (
+ (key !== undefined && key.typeId !== TypeId.UNKNOWN) ||
+ (value !== undefined && value.typeId !== TypeId.UNKNOWN)
+ );
+ }
+ return false;
+}
+
export function checkFieldId(fieldId: number) {
if (Number.isFinite(fieldId) && fieldId < 0) {
throw new Error("field id must be non-negative");
diff --git a/javascript/test/array.test.ts b/javascript/test/array.test.ts
index 989bbf7f8..9073fb11a 100644
--- a/javascript/test/array.test.ts
+++ b/javascript/test/array.test.ts
@@ -77,6 +77,56 @@ describe("array", () => {
expect(deserialize(serialize({ c: [o, o] }))).toEqual({ c: [o, o] });
});
+ test("should root list use declared element type", () => {
+ // A root Type.list(...) registration previously fell back to the internal
+ // any-typed list serializer, silently discarding declared element types:
+ // declared float32 must narrow, while dynamic dispatch keeps float64.
+ const fory = new Fory({ compatible: false });
+ const { serialize, deserialize } =
fory.register(Type.list(Type.float32()));
+ expect(deserialize(serialize([0.1]))).toEqual([Math.fround(0.1)]);
+
+ // The dynamic list serializer must stay untouched by the registration.
+ expect(fory.deserialize(fory.serialize([0.1, "a"]))).toEqual([0.1, "a"]);
+ });
+
+ test("should root set use declared element type", () => {
+ const fory = new Fory({ compatible: false });
+ const { serialize, deserialize } = fory.register(Type.set(Type.float32()));
+ expect(deserialize(serialize(new Set([0.1])))).toEqual(new
Set([Math.fround(0.1)]));
+ expect(fory.deserialize(fory.serialize(new Set([0.1, "a"])))).toEqual(new
Set([0.1, "a"]));
+ });
+
+ test("should root container registered before its ext codec work", () => {
+ // Registration order is free before the first root operation: the
+ // container's forward ext placeholder must be filled when the extension
+ // codec registers later, so the generated serializer binds to the
+ // completed codec instead of capturing undefined.
+ class ListedExtension {
+ constructor(public id = 0) {}
+ }
+ Type.ext(921)(ListedExtension);
+ const extCodec = {
+ write(context: any, value: ListedExtension) {
+ context.writeUint8(value.id);
+ },
+ read(context: any, result: ListedExtension) {
+ result.id = context.readUint8();
+ },
+ };
+
+ const listFory = new Fory({ compatible: false });
+ const list = listFory.register(Type.list(Type.ext(921)));
+ listFory.register(ListedExtension, extCodec);
+ const listResult = list.deserialize(list.serialize([new
ListedExtension(7)]));
+ expect(listResult).toEqual([new ListedExtension(7)]);
+
+ const setFory = new Fory({ compatible: false });
+ const set = setFory.register(Type.set(Type.ext(921)));
+ setFory.register(ListedExtension, extCodec);
+ const setResult = set.deserialize(set.serialize(new Set([new
ListedExtension(9)])));
+ expect(setResult).toEqual(new Set([new ListedExtension(9)]));
+ });
+
test("preserves a self-reference in a dynamic list", () => {
const fory = new Fory({ compatible: false, ref: true });
const value: any[] = [];
diff --git a/javascript/test/map.test.ts b/javascript/test/map.test.ts
index 367170aff..025ada82f 100644
--- a/javascript/test/map.test.ts
+++ b/javascript/test/map.test.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import Fory, { Type } from "../packages/core/index";
+import Fory, { ReadContext, Type, WriteContext } from "../packages/core/index";
import { CodegenRegistry } from "../packages/core/lib/gen/router";
import { BinaryReader } from "../packages/core/lib/reader";
import { ConfigFlags, RefFlags, TypeId } from "../packages/core/lib/type";
@@ -97,6 +97,44 @@ describe("map", () => {
});
});
+ test("should root map use declared key and value types", () => {
+ // A root Type.map(...) registration previously fell back to the internal
+ // any-typed map serializer, silently discarding declared key/value types:
+ // declared float32 must narrow, while dynamic dispatch keeps float64.
+ const fory = new Fory({ compatible: false });
+ const { serialize, deserialize } = fory.register(Type.map(Type.string(),
Type.float32()));
+ expect(deserialize(serialize(new Map([["a", 0.1]])))).toEqual(
+ new Map([["a", Math.fround(0.1)]]),
+ );
+
+ // The dynamic map serializer must stay untouched by the registration.
+ expect(fory.deserialize(fory.serialize(new Map([[1, "x"]])))).toEqual(new
Map([[1, "x"]]));
+ });
+
+ test.each([false, true])("registers map before ext codec (%s)", (compatible)
=> {
+ class MapExtension {
+ constructor(public id = 0) {}
+ }
+ Type.ext(922)(MapExtension);
+
+ const fory = new Fory({ compatible });
+ const keys = fory.register(Type.map(Type.ext(922), Type.string()));
+ const values = fory.register(Type.map(Type.string(), Type.ext(922)));
+ fory.register(MapExtension, {
+ write(context: WriteContext, value: MapExtension) {
+ context.writeUint8(value.id);
+ },
+ read(context: ReadContext, result: MapExtension) {
+ result.id = context.readUint8();
+ },
+ });
+
+ const keyInput = new Map([[new MapExtension(7), "key"]]);
+ const valueInput = new Map([["value", new MapExtension(9)]]);
+ expect(keys.deserialize(keys.serialize(keyInput))).toEqual(keyInput);
+
expect(values.deserialize(values.serialize(valueInput))).toEqual(valueInput);
+ });
+
test("preserves shared dynamic map entries", () => {
const fory = new Fory({ compatible: false, ref: true });
@Type.struct(301, {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]