Skip to content

Minify state var names and allow installing a name resolver after states exist - #7337

Open
benedikt-bartscher wants to merge 202 commits into
reflex-dev:mainfrom
benedikt-bartscher:late-bound-wire-names
Open

benedikt-bartscher wants to merge 202 commits into
reflex-dev:mainfrom
benedikt-bartscher:late-bound-wire-names

Conversation

@benedikt-bartscher

@benedikt-bartscher benedikt-bartscher commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #6100 — the diff includes its commits until that merges. The changes specific to this PR are in fb6992b and the merges of explicit-event-id-minification that follow it.

What this adds

Var name minification. REFLEX_MINIFY_VARS=1 shortens state var keys on the wire using a new optional vars section in minify.json (still schema version 1; older files load with no vars). A minified key is just its id, so a delta goes from {"a.b": {"items_rx_state_": []}} to {"a.b": {"c": []}}. reflex minify init/sync/validate/list/lookup cover vars, and the scheme digest includes them. Var ids that every JS object already has (constructor, __proto__, …) or that end in _rx_state_ are rejected.

A name resolver can be installed after states exist. RegistrationContext.set_name_resolver rebuilds the Vars every registered state holds. Backend dependency tracking and VarData.field_dependencies now key on classes and the resolver-independent default name, so they never go stale. This lets in-process AppHarness tests run fully minified. A Var built from state Vars before the install (e.g. a module-level component imported before minify.json was loaded) is not fixed; compiling fails with a message naming each stale reference instead of shipping a broken frontend.

One place builds wire keys. format_var_key / format_state_var (which reads through the prefixed local format_state_local from #6100) replace the ad-hoc name + FIELD_MARKER concatenations; update_vars_internal maps keys back through a per-state lookup, and state.js reads the hydrated flag's key from the compiled context instead of hardcoding it.

Notes

  • setvar still sends the Python var name: it dispatches through the class hierarchy, where a per-state id would be ambiguous.
  • REFLEX_PERF_MODE's check for stringified vars keys on the field marker, so it doesn't see minified vars.
  • Router fields and is_hydrated are minified too; nothing in the frontend reads them by name anymore.
  • New public API: NameResolver.resolve_var_name, reflex_base.utils.format.format_var_key / format_state_var.

Testing

  • Unit tests for var ids, config validation, sync/prune, the stale-name check, rebuilding Vars on a late install, dependency tracking and always-dirty substates under renamed states, client-storage compile, update_vars_internal with minified keys, and the CLI.
  • tests/integration/test_minification.py now runs in-process with framework states and vars minified.
  • A downstream app driven by Playwright in prod with all three modes on: inherited, computed and backend vars, background tasks, ComponentState, cookie/localStorage persistence across tabs, dynamic routes; websocket frames carry no var or state names.

🤖 Generated with Claude Code

benedikt-bartscher and others added 30 commits January 24, 2026 20:43
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
When a parent and child state have the same minified name, substate
resolution can fail because the leading segment is stripped incorrectly. This
change adds a flag to skip stripping only on the initial recursive call, ensuring
correct resolution even in name collision scenarios.
@masenf masenf added the perf Performance-improving changes label Sep 29, 2026
…inification

- state.js: keep the main_state_name prefix check in isStateful, with main's
  non-string event name guard and nested event normalization.
- vars/base.py, tests: keep both sides' imports and new tests.
- docs/api-reference/cli.md: keep compile and minify, drop the deleted
  component command.
- test_context_js_template.py: context_template no longer takes state_name.
…into late-bound-wire-names

- tests/benchmarks/test_shared_state.py: _always_dirty_substates holds state
  classes, so the shared-state harness adds SharedStateBaseInternal itself.
@masenf masenf added the documentation Improvements or additions to documentation label Oct 6, 2026
Ported upstream changes into the resolver-aware code:

- Context template: hydrate_and_load (reflex-dev#7064) gets a resolved wire name in
  InternalNames (replacing hydrate), alongside upstream's state schema and
  initial state hashes. state.js reads app.hydrate_and_load and
  app.update_vars_internal instead of hardcoded default names, and the
  handshake query (token + scheme) is built for the warm-up socket too.
- State reads use upstream's useStateContext (reflex-dev#7441), keyed by the
  state's resolved full name, into the branch's prefixed local.
- Upstream made unknown substates non-fatal (reflex-dev#7435) and dropped
  backend_state_mismatch; the fatal minification scheme mismatch keeps its
  own flag (backend_scheme_mismatch) gating events, updates and reconnects.
- The frontend state schema hash (reflex-dev#7553) hashes resolver-independent names;
  the scheme digest already covers the wire names.
- The processor's hydrate/hydrate_and_load/on_load_internal lookups cache
  handlers, not names, so they follow a resolver installed later.
- BaseStateToken._state_key and client storage application
  (_apply_client_storage_vars) use default names and wire var keys.
- get_substate keeps upstream's loop but only lets the first segment name
  the state itself; get_name/get_full_name stay function-wide caches so a
  resolver install clears every class, and also clears _get_substate_path.
- on_connect sets up per-SID state before upstream's new first await.
- pyStrAt avoids Array.prototype.at, which the state.js tests forbid.
Comment thread reflex/minify.py
Comment thread packages/reflex-base/src/reflex_base/registry.py Outdated
…inification

Ported upstream changes into the resolver-aware code:

- Context template: hydrate_and_load (reflex-dev#7064) gets a resolved wire name in
  InternalEventNames (replacing hydrate), alongside upstream's state schema
  and initial state hashes. state.js reads app.hydrate_and_load and
  app.update_vars_internal instead of hardcoded default names, and the
  handshake query (token + scheme) is built for the warm-up socket too.
- State reads use upstream's useStateContext (reflex-dev#7441), keyed by the
  state's resolved full name, into the prefixed local.
- Upstream made unknown substates non-fatal (reflex-dev#7435) and dropped
  backend_state_mismatch; the fatal minification scheme mismatch keeps its
  own flag (backend_scheme_mismatch) gating events, updates and reconnects.
- The frontend state schema hash (reflex-dev#7553) hashes resolver-independent names;
  the scheme digest already covers the wire names.
- The processor's hydrate/hydrate_and_load/on_load_internal lookups cache
  handlers, not names, so they follow a resolver installed later.
- BaseStateToken._state_key keys by the resolver-independent name.
- get_substate keeps upstream's loop but only lets the first segment name
  the state itself; get_name/get_full_name stay function-wide caches so a
  resolver install clears every class, and also clears _get_substate_path.
- on_connect sets up per-SID state before upstream's new first await.
- pyStrAt avoids Array.prototype.at, which the state.js tests forbid.
…into late-bound-wire-names

Brings in reflex-dev#6100's merge of upstream/main. Its conflicts here are the
reflex-dev#6100 spellings of code this branch already resolved (InternalEventNames
vs InternalNames, the hook's state name, tests of state/event-only
minification), so this branch's versions are kept and the tree is
unchanged.
A handler with no minify.json entry keeps its Python name, which can be
the id another handler of its state was given, e.g. when it is added
before 'reflex minify sync' runs. Registering it overwrote the other,
so an event for one ran the other. Registration now raises when two
handlers of a state resolve to the same name.

refresh_keys rebuilt the substate sets from base_states, which still
holds a state a module reload removed, so installing a resolver after
such a reload put the removed state back into the tree. It now re-keys
the substate sets as they are.
sync compared decoded ids, so a hand-edited "aa" counted as a collision
with "a" and got rehomed; validate and the wire compare the strings.
…inification

The reconnect back-off from upstream counts a disconnect before the connect
handler runs as a failed attempt. A fatal scheme mismatch disconnects in
exactly that window, since socket.io replays it before "connect", so the
retry appended a non-fatal error and the connect handler then discarded the
out-of-date notice. A client-initiated disconnect no longer counts as a
failed attempt; the state.js runtime test mock now replays buffered events
and reports client disconnects like socket.io-client to cover it.

The hydration toast test now locates the error threshold through
has_too_many_connection_errors, which this branch extends with the fatal
check.
_assign_next_ids compared decoded ids, so a held "aa" kept "a" from being
handed out to a new state, event, or duplicate. It now compares the id
strings, as validate and the wire do.
A segment that is one handler's Python name and another handler's id
resolved only to the id holder. lookup now lists both and warns which one
applies with REFLEX_MINIFY_EVENTS on or off.
_sync_member_ids now passes the raw id strings to _assign_next_ids, so new
vars get the string-compared allocation along with events.

minify lookup's loop over events and vars keeps the live id holders and adds
the member a segment names when that is a different one, warning which reading
applies with REFLEX_MINIFY_EVENTS or REFLEX_MINIFY_VARS on or off. Its
cross-kind warning counts each kind once, since several members of one kind
are already warned about. The lookup test gains a var case.
Comment thread reflex/minify.py Outdated
Comment thread reflex/minify.py
Comment on lines +768 to +776
if references:
for match in _DEFAULT_NAME_RUN.finditer(code):
prefix = ""
for segment in match.group().split("."):
prefix = f"{prefix}.{segment}" if prefix else segment
if prefix in references:
found.setdefault(prefix, (references[prefix], path))
break
for match in STATE_MEMBER_READ.finditer(code):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Old events survive compilation

Events built under an earlier minified scheme can pass raise_for_stale_names unchanged. A module-level LiteralEventVar can store a.b.c; after the handler's ID changes to d, the default-name scan cannot match a.b.c, and the state-local scan does not inspect event strings. Compilation succeeds with an event the backend no longer registers—or now assigns to another handler.

The bundle carries the current scheme digest, so the handshake does not catch this. Track resolved event names when they are built, or rebuild these event Vars when installing the resolver.

Upstream's browser-storage type fix (reflex-dev#7603) widened the field's plain types
where _init_var also bound the field's Var. Here _bind_var does the binding,
so the widening stays in _init_var, which runs once per field, and the Var
binding is left to _bind_var.
A handler name now counts as a stale default reference only when no handler of
that state currently goes by it as its wire name.
Record the handlers each event name is handed out for and reject a
compiled ReflexEvent whose name none of them goes by any more, so an
event built under a previous minify.json can't reach a vanished or
reassigned handler. Registry lookups compute names without recording
them, so a reassigned id doesn't count as handed out.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/reflex-base/src/reflex_base/registry.py">

<violation number="1" location="packages/reflex-base/src/reflex_base/registry.py:76">
P2: This drops empty resolver segments, so registry keys diverge from the event names emitted by `format_event_handler` for a valid custom resolver. Preserve the formatter’s `handler.state is None` branch to keep inbound event dispatch consistent.</violation>
</file>

<file name="reflex/minify.py">

<violation number="1" location="reflex/minify.py:766">
P2: This live-name check can accept an event for a handler removed by a state reload because it consults the old issuer's resolver mapping without checking the current event registry. Verify that each issuer still has the recorded handler registered before returning it as live; otherwise compilation can emit an event the backend no longer handles.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

"""
from reflex_base.utils.format import get_event_handler_parts

return ".".join(filter(None, get_event_handler_parts(handler)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This drops empty resolver segments, so registry keys diverge from the event names emitted by format_event_handler for a valid custom resolver. Preserve the formatter’s handler.state is None branch to keep inbound event dispatch consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/reflex-base/src/reflex_base/registry.py, line 76:

<comment>This drops empty resolver segments, so registry keys diverge from the event names emitted by `format_event_handler` for a valid custom resolver. Preserve the formatter’s `handler.state is None` branch to keep inbound event dispatch consistent.</comment>

<file context>
@@ -59,6 +59,23 @@ def _rekey(
+    """
+    from reflex_base.utils.format import get_event_handler_parts
+
+    return ".".join(filter(None, get_event_handler_parts(handler)))
+
+
</file context>
Suggested change
return ".".join(filter(None, get_event_handler_parts(handler)))
state, name = get_event_handler_parts(handler)
return name if handler.state is None else f"{state}.{name}"

Comment thread reflex/minify.py
for state_cls, handlers in issuers.items():
state_name = state_cls.get_full_name()
for handler in handlers:
if f"{state_name}.{ctx.get_handler_name(state_cls, handler)}" == name:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This live-name check can accept an event for a handler removed by a state reload because it consults the old issuer's resolver mapping without checking the current event registry. Verify that each issuer still has the recorded handler registered before returning it as live; otherwise compilation can emit an event the backend no longer handles.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/minify.py, line 766:

<comment>This live-name check can accept an event for a handler removed by a state reload because it consults the old issuer's resolver mapping without checking the current event registry. Verify that each issuer still has the recorded handler registered before returning it as live; otherwise compilation can emit an event the backend no longer handles.</comment>

<file context>
@@ -735,17 +735,53 @@ def _renamed_default_references() -> dict[str, str]:
+    for state_cls, handlers in issuers.items():
+        state_name = state_cls.get_full_name()
+        for handler in handlers:
+            if f"{state_name}.{ctx.get_handler_name(state_cls, handler)}" == name:
+                return None
+            labels.append(f"event handler {get_state_full_path(state_cls)}.{handler}")
</file context>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation perf Performance-improving changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants