diff --git a/javascript/packages/core/lib/fory.ts b/javascript/packages/core/lib/fory.ts index 8eff8df042..04cd1fd6e4 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 9e62446722..9b9a4f8416 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/typeInfo.ts b/javascript/packages/core/lib/typeInfo.ts index 95ff4a0a4d..a92f438c56 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 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 989bbf7f8b..9073fb11aa 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 367170aff9..b3b5828923 100644 --- a/javascript/test/map.test.ts +++ b/javascript/test/map.test.ts @@ -97,6 +97,20 @@ 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("preserves shared dynamic map entries", () => { const fory = new Fory({ compatible: false, ref: true }); @Type.struct(301, {