Skip to content

Single-pass compiler: Make Component effectively immutable #6214

Description

@masenf

Summary

Make Component an effectively immutable type after construction. Immutability is a correctness and simplicity win — it eliminates a class of bugs caused by post-creation mutation and makes the component tree easier to reason about during compilation. It also enables caching opportunities (see below), though the primary caching mechanism for cross-reload use will be structural hashing (ENG-9148), not id()-based identity.

Related to ENG-9142, ENG-9143, ENG-9145, ENG-9148.

Background

Today, Component instances are mutable. After Component.create(), various passes modify the component in place:

  • _add_style_recursive() mutates self.style on every component in the tree
  • StatefulComponent.compile_from() replaces component.children with memoized versions
  • Various compiler phases read and write component attributes

This mutability means the compiler can't make assumptions about whether a component has been modified between phases, and it complicates any caching strategy.

Proposed Approach

Frozen-after-construction pattern

After Component.create() returns, the component should be treated as immutable. Any "mutation" creates a new component instance (copy-on-write):

# Instead of:
component.style = new_style  # mutation!

# Do:
component = component.copy_with(style=new_style)  # returns new instance

This can be enforced by:

  1. Adding a _frozen flag that's set after create() completes
  2. Overriding __setattr__ to raise when _frozen is True (with an escape hatch for the construction phase)
  3. Providing a copy_with(**kwargs) method that creates a shallow copy with updated fields

Caching benefits

Immutability enables two levels of caching:

Within a single compilation run, id()-based caching is useful when the exact same component object appears in multiple places (e.g., a shared layout component stored in a module-level variable). Since it's frozen, its id() is a stable cache key for the duration of the run.

Across hot reloads, id()-based caching is not sufficient — common patterns like helper functions (navbar(), sidebar()) recreate structurally identical component trees with new id() values on every call. Cross-reload caching requires structural hashing as described in ENG-9148. Immutability supports this by making _structural_hash a reliable cached_property — once computed, it never needs recomputation since the component can't change.

Impact on existing code

The main places that currently mutate components post-creation:

  1. _add_style_recursive() — Must become a copy-on-write operation. The ApplyStylePlugin would return new component instances rather than mutating in place. This is the biggest change.
  2. StatefulComponent.compile_from() — Sets component.children = [...]. With the new memo plugin (ENG-9145), this goes away entirely.
  3. add_meta() in compiler/utils.py — Appends to page.children. Should create a new component instead.
  4. Various __init__ / create() post-processing — These happen during construction and are fine.

Acceptance Criteria

  • Component instances are frozen after create() returns
  • __setattr__ raises AttributeError on frozen components (with clear error message)
  • copy_with(**kwargs) method is available for creating modified copies
  • _add_style_recursive is refactored to return new component instances instead of mutating
  • add_meta() in compiler/utils.py is refactored to not mutate the component
  • All existing tests pass
  • Verify that cached_property works correctly on frozen components (for future use by _structural_hash in ENG-9148)

Key Files

  • reflex/components/component.py:
    • Component class — needs frozen flag and __setattr__ override
    • BaseComponent.create() — set frozen flag after construction
    • _add_style_recursive() (~line 1251) — needs copy-on-write refactor
  • reflex/compiler/utils.py:
    • add_meta() (~line 417) — mutates page.children
  • reflex/compiler/compiler.py:
    • compile_unevaluated_page() — calls _add_style_recursive and add_meta

Notes

  • The frozen pattern is inspired by Python's dataclasses(frozen=True) but needs to be more flexible since Component uses Pydantic-style field definitions.
  • Consider using __slots__ where possible for memory efficiency, though this may conflict with Pydantic's model system.
  • Immutability is a prerequisite for the structural hashing in ENG-9148_structural_hash as a cached_property only works if the component's type, props, and children can never change after construction.
  • This issue focuses on making components immutable and refactoring mutation sites. The caching strategies themselves (both id()-based within a run and structural-hash-based across reloads) are covered in ENG-9148.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementAnything you want improved

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions