Skip to content

Fix MutableInterfaceType not frozen in mutation-only return types#790

Merged
oojacoboo merged 1 commit intothecodingmachine:masterfrom
oojacoboo:fix/mutable-interface-freeze
Apr 7, 2026
Merged

Fix MutableInterfaceType not frozen in mutation-only return types#790
oojacoboo merged 1 commit intothecodingmachine:masterfrom
oojacoboo:fix/mutable-interface-freeze

Conversation

@oojacoboo
Copy link
Copy Markdown
Collaborator

Description

Fixes #308MutableInterfaceType instances escape the freeze mechanism when used exclusively as mutation return types (no corresponding query returning the same interface). This causes RuntimeException: You must freeze() a MutableObjectType before fetching its fields during schema validation, introspection, and query execution.

Root Cause

Two code paths in RecursiveTypeMapper allowed MutableInterfaceType to bypass freezing:

1. findInterfaces() created orphaned, unfrozen instances

findInterfaces() called $this->typeMapper->mapClassToType() (the underlying type mapper) instead of going through RecursiveTypeMapper::mapClassToType(). The underlying mapper (via TypeGenerator) creates a new MutableInterfaceType instance that is:

  • Not registered in the TypeRegistry
  • Not extended via @ExtendType
  • Not frozen

Meanwhile, RecursiveTypeMapper::mapClassToType() later creates a different instance of the same type that is properly frozen and registered. This leaves webonyx holding a reference to the orphaned unfrozen instance (from ObjectType::getInterfaces()), while the TypeRegistry contains the frozen one.

Since webonyx's TypeInfo::extractTypes() processes getInterfaces() before getFields(), the unfrozen interface is discovered before the fields callback (which would trigger proper mapping) has a chance to run.

2. mapNameToType() didn't freeze MutableInterfaceType

The freeze logic in mapNameToType() handled MutableObjectType and input types but had no branch for MutableInterfaceType. If an interface type was loaded by name through the typeLoader before being mapped through mapClassToType(), it was returned in PENDING state.

Fix

  1. findInterfaces(): Now calls $this->mapClassToType() (RecursiveTypeMapper's own method) before retrieving the interface type, ensuring the instance is registered, extended, and frozen through the proper pipeline.

  2. mapNameToType(): Extended the freeze logic to also handle MutableInterfaceType, including applying type extensions before freezing — matching the existing pattern for MutableObjectType.

Why not the finalizeTypes() approach (#789)?

PR #789 proposes brute-force freezing all registered types in TypeRegistry::finalizeTypes() before schema creation. While well-intentioned, this doesn't address the root cause:

  • findInterfaces() creates orphaned instances that are never registered in the TypeRegistry, so finalizeTypes() misses them entirely
  • Types created during lazy resolution (after finalizeTypes() has already run) would still be unfrozen
  • mapNameToType() still wouldn't freeze MutableInterfaceType at runtime
  • It skips the extension step that mapClassToType() applies before freezing

Test Plan

  • 4 new regression tests in MutationInterfaceFreezeTest:
    • Schema validation doesn't throw for mutation-only interface return types
    • Mutation execution returns correct data
    • Inline fragments on interface types in mutations work correctly
    • Introspection resolves the interface type without errors
  • Tests fail on master with the exact error from Exception "You must freeze() a MutableObjectType before fetching its fields" gets thrown #308, pass with the fix
  • All 493 tests pass (489 existing + 4 new)

… types (thecodingmachine#308)

Two code paths in RecursiveTypeMapper allowed MutableInterfaceType to
escape the freeze mechanism:

1. findInterfaces() called the underlying typeMapper directly, creating
   orphaned instances never registered in TypeRegistry or frozen.

2. mapNameToType() handled MutableObjectType and input types but not
   MutableInterfaceType in its freeze logic.
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 94.81%. Comparing base (53f9d49) to head (d3e0b9c).
⚠️ Report is 138 commits behind head on master.

Files with missing lines Patch % Lines
src/Mappers/RecursiveTypeMapper.php 66.66% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #790      +/-   ##
============================================
- Coverage     95.72%   94.81%   -0.91%     
- Complexity     1773     1852      +79     
============================================
  Files           154      175      +21     
  Lines          4586     4884     +298     
============================================
+ Hits           4390     4631     +241     
- Misses          196      253      +57     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@michael-georgiadis michael-georgiadis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good stuff! Thanks for adding 🫡

@oojacoboo oojacoboo merged commit dd1fc8b into thecodingmachine:master Apr 7, 2026
11 checks passed
@oojacoboo oojacoboo deleted the fix/mutable-interface-freeze branch April 7, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exception "You must freeze() a MutableObjectType before fetching its fields" gets thrown

3 participants