Skip to content

Commit 5bc5923

Browse files
authored
fix(candid): recursive type table merging preserves concrete type mapping (#1153)
Fixes demergent-labs/azle#1526.
1 parent b570114 commit 5bc5923

File tree

3 files changed

+77
-5
lines changed

3 files changed

+77
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [Unreleased]
44

5+
- fix(candid): recursive type table merging preserves concrete type mapping
56
- fix(identity): handle ArrayBuffer in delegation chain serialization
67

78
## [4.0.4] - 2025-09-18

packages/candid/src/idl.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,69 @@ test('encode / decode recursive types with shared non-recursive types', () => {
685685
expect(decoded).toEqual(toEncode);
686686
});
687687

688+
test('encode / decode recursive record with shared opt nat8', () => {
689+
const MyRecursiveOpt = IDL.Rec();
690+
MyRecursiveOpt.fill(IDL.Opt(IDL.Nat8));
691+
692+
const MyRecursiveRecord = IDL.Rec();
693+
MyRecursiveRecord.fill(
694+
IDL.Record({
695+
field1: IDL.Opt(IDL.Nat8),
696+
field2: IDL.Record({
697+
field3: MyRecursiveOpt,
698+
}),
699+
}),
700+
);
701+
702+
expect(() => {
703+
const encoded = IDL.encode(
704+
[MyRecursiveRecord],
705+
[
706+
{
707+
field1: [],
708+
field2: {
709+
field3: [],
710+
},
711+
},
712+
],
713+
);
714+
IDL.decode([MyRecursiveRecord], encoded);
715+
}).not.toThrow();
716+
});
717+
718+
test('encode / decode recursive types sharing opt nat8 across record and func return', () => {
719+
const recRecordFunc = IDL.Rec();
720+
const recOpt = IDL.Rec();
721+
recRecordFunc.fill(IDL.Record({ field1: IDL.Func([], [IDL.Opt(IDL.Nat8)]), field2: recOpt }));
722+
recOpt.fill(IDL.Record({ field3: IDL.Opt(IDL.Nat8) }));
723+
724+
const recRecordOpt = IDL.Rec();
725+
const recOpt2 = IDL.Rec();
726+
recOpt2.fill(IDL.Opt(IDL.Nat8));
727+
recRecordOpt.fill(IDL.Record({ field4: IDL.Opt(IDL.Nat8), field5: recOpt2 }));
728+
729+
const myRecRecordFunc = {
730+
field1: [
731+
Principal.fromText('bpyi5-2bzji-dsb2y-bav6a-jeig6-eakqa-ibawc-73crj-st4ac-berzm-mwy'),
732+
'',
733+
],
734+
field2: {
735+
field3: [],
736+
},
737+
};
738+
const myRecRecordOpt = {
739+
field4: [],
740+
field5: [],
741+
};
742+
const encodedRecRecordFunc = IDL.encode([recRecordFunc], [myRecRecordFunc]);
743+
const encodedRecRecordOpt = IDL.encode([recRecordOpt], [myRecRecordOpt]);
744+
745+
expect(() => {
746+
IDL.decode([recRecordFunc], encodedRecRecordFunc);
747+
IDL.decode([recRecordOpt], encodedRecRecordOpt);
748+
}).not.toThrow();
749+
});
750+
688751
test('decode / encode unknown nested record', () => {
689752
const nestedType = IDL.Record({ foo: IDL.Int32, bar: IDL.Bool });
690753
const recordType = IDL.Record({
@@ -1156,8 +1219,8 @@ describe('IDL subtyping', () => {
11561219

11571220
describe('Subtyping on records/variants normalizes field labels', () => {
11581221
// Checks we don't regress https://github.com/dfinity/icp-js-core/issues/1072
1159-
testSub(IDL.Record({ a: IDL.Nat, "_98_": IDL.Nat }), IDL.Record({ "_97_": IDL.Nat, b: IDL.Nat }));
1160-
testSub(IDL.Variant({ a: IDL.Nat, "_98_": IDL.Nat }), IDL.Variant({ "_97_": IDL.Nat, b: IDL.Nat }));
1222+
testSub(IDL.Record({ a: IDL.Nat, _98_: IDL.Nat }), IDL.Record({ _97_: IDL.Nat, b: IDL.Nat }));
1223+
testSub(IDL.Variant({ a: IDL.Nat, _98_: IDL.Nat }), IDL.Variant({ _97_: IDL.Nat, b: IDL.Nat }));
11611224
});
11621225

11631226
describe('decoding function/service references', () => {

packages/candid/src/idl.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,21 @@ class TypeTable {
7979
if (knotIdx === undefined) {
8080
throw new Error('Missing type index for ' + knot);
8181
}
82+
// Point the recursive placeholder (obj) to the concrete type bytes
8283
this._typs[idx] = this._typs[knotIdx];
8384

84-
// Decrement reference count since we're removing the knot name mapping
85+
// Merge mappings so BOTH names point to the same index
86+
// This avoids losing lookups for the concrete type name (e.g. "opt nat8")
87+
// which may still be referenced elsewhere in the type table build.
88+
const idxRefCount = this._getIdxRefCount(idx);
8589
const knotRefCount = this._getIdxRefCount(knotIdx);
86-
this._idxRefCount.set(knotIdx, knotRefCount - 1);
87-
this._idx.delete(knot);
90+
this._idxRefCount.set(idx, idxRefCount + knotRefCount);
8891

92+
// Re-point the knot name to the resolved index and mark the old index as unused
93+
this._idx.set(knot, idx);
94+
this._idxRefCount.set(knotIdx, 0);
95+
96+
// Remove unused trailing entries if possible
8997
this._compactFromEnd();
9098
}
9199

0 commit comments

Comments
 (0)