Treat None as empty in optional set and list var operations

Merged
MA
4d ago
+555-57

Fixes #7826, fixes #7827, and fixes FINDING-036 from the reflex 0.11.0a1 pre-release testing (artifacts on branch claude/vibrant-pasteur-3921g3, prerelease-testing/runs/2026-11-11-v0.11.0a1/client_vars/ and verify_cv/).

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • This change requires a documentation update (included)

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

Defect

Operations on a set[T] | None / frozenset[T] | None var holding None threw TypeError: Cannot read properties of null (reading 'some') in pySetHas, breaking the page in dev and failing the prod prerender (reflex export exit 1, Prerender: Request failed for /setnone/). SetVar is new in 0.11.0 (#7642); None in an optional set should act as an empty set.

Fix

  • utils/helpers/set.js: every pySet* helper reads a null/undefined operand (either side) as an empty set — one comparison on the non-null path, no allocation.
  • SetVar.to_array() of an optional set compiles to (x ?? []), typed Sequence[T], so length, rx.foreach and the deprecated array operations (which go through to_array()) are null-safe too. A non-optional set compiles exactly as before.
  • intersection / difference of an optional set are typed non-optional (the helpers always return an array), and frozenset[T] | None keeps frozenset through union / symmetric_difference.
  • ArrayVar (vars/sequence.py): every operation that reads the array (length, contains, join, reverse, slicing, +, *, pluck, map, filter, reduce, flat_map) goes through one array_or_empty_operation view, (x ?? []), when the var type allows None (#7826). SetVar.to_array() reuses the same operation. Indexing was already null-safe (x?.at?.(i)), and a required list compiles exactly as before.
  • ComputedVar / AsyncComputedVar.__get__: the list, tuple, set and frozenset overloads also accept the optional form, so -> set[str] | None reads as SetVar[set[str]] and -> list[str] | None as ArrayVar[list[str]] for pyright (#7827).
  • Docs: the Set Operations section of docs/vars/var-operations.md states the None-as-empty rule; the note is folded into the unreleased +set-var.feature.md entry. The Contains, Reverse and Join section states the same rule for optional lists; two bugfix fragments cover the list and computed-var changes.
op on a None set (either operand may be None)result
containsfalse
length0
union(x) / symmetric_difference(x)x
intersection / difference[]
issubset / isdisjointtrue
issuperset(x)true only when x is empty
to_array()[]
rx.foreach / rx.cond / rx.matchno children / false branch / default

Test plan

  • tests/js/set.test.js (new): every helper with null and undefined operands — before 8 failed / 31 passed (TypeError: Cannot read properties of null (reading 'some'), reading 'map'), after 39/39.
  • tests/units/reflex_base/vars/test_set.py: compiled JS and result types of every operation on optional sets (state fields, computed vars, dataclass fields, either operand optional, frozenset), deprecated operations — before 11 failed, after 60 passed.
  • tests/integration/tests_playwright/test_set_operations.py: 14 optional-set outputs while unset, after assignment and after a reset to None; the fixture now uses app_harness_env, so it runs in dev and prod (the bug was in the prod prerender) — before: dev [Reflex Frontend Exception] TypeError ... at pySetHas, prod Creating Production Build failed with exit code 1; after: 2 passed.
  • tests/units/reflex_base/vars/test_sequence.py (new): 14 array operations on list[T] | None and tuple[T, ...] | None compile through (x ?? []), and a required list has no guard — before 29 failed, after 43 passed.
  • tests/units/reflex_base/vars/test_base.py: assert_type of optional list, tuple, set, frozenset and async computed vars — before pyright reported 6 reportAssertTypeFailure, after 0 errors.
  • tests/integration/tests_playwright/test_var_operations.py: length, contains and join of an optional list while None and while set — before TypeError: Cannot read properties of null (reading 'length'), after passed.
  • Full unit suite: 15661 passed, 66 skipped, 81.37% (the Playwright browser-driver module could not run locally for lack of a Chromium binary). ruff, pyright reflex tests, pre-commit, reflex-release changelog-check: clean.
  • End to end (QA app, 28 optional-set outputs): dev and reflex run --env prod pass with the set None, set and unset again; the prerendered HTML already holds the empty-set results; reflex export --frontend-only exits 0 (1 before).

Notes

  • maybe.to_array() is now never None (maybe.to_array() == None is false) — a change to an unreleased API.

Generated by Claude Code