Speed up memo-name hashing, move it into its own module, fix seven tag collisions - #6947
Conversation
The hash feeds every value one `hasher.update()` call at a time and walks the full `isinstance` ladder per node, so a single component hash costs tens of thousands of C calls. On a foreach/cond-heavy page, `_get_component_hash` is ~50% of compile wall time. Encode into a `bytearray` flushed to the hasher in 64KB chunks instead of per node, dispatch on the exact type before falling back to the `isinstance` ladder for subclasses, cache each dataclass type's field layout with pre-encoded names, and cache the encoded form of short strings and of `ImportVar` instances (a frozen dataclass of `str`/`bool`/`None` fields, so its generated equality means exactly "same encoding", and it accounts for most of what a component hash consumes: 5664 visits across just 12 distinct values on one benchmark page). The byte stream is unchanged, so every digest is identical to before — verified against a copy of the previous implementation over all values hashed while compiling four benchmark pages. 2.3-2.6x faster on the large pages, 1.8-1.9x on the small ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
Merging this PR will improve performance by 4.8%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | test_compile_all_artifacts[_stateful_page] |
26.9 ms | 25.5 ms | +5.48% |
| ⚡ | Simulation | test_compile_page[_stateful_page] |
30.4 ms | 29 ms | +4.84% |
| ⚡ | Simulation | test_compile_page_full_context[_stateful_page] |
34.4 ms | 33.1 ms | +4.07% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/optimize-deterministic-hash-u1dl2j (0d12a44) with main (3013a6b)2
Footnotes
-
8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
-
No successful run was found on
main(6beb371) during the generation of this report, so 3013a6b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩
Greptile SummaryThe PR moves deterministic component hashing into a dedicated reflex-base utility, expands memo-name inputs to prevent collisions, and clears encoder caches after compilation.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| packages/reflex-base/src/reflex_base/utils/deterministic_hash.py | Introduces the buffered deterministic encoder, type dispatch, bounded caches, and explicit cache cleanup. |
| packages/reflex-base/src/reflex_base/components/memo.py | Centralizes component artifact hashing and memo-tag generation while covering additional compiled artifacts. |
| packages/reflex-base/src/reflex_base/components/component.py | Removes the former component-local hashing and memo-tag implementation. |
| reflex/app.py | Ensures deterministic-hash caches are cleared after every compile attempt. |
| tests/units/components/test_memo.py | Adds regression coverage for memo-name collision cases and artifact-sensitive hashing. |
| tests/units/reflex_base/utils/test_deterministic_hash.py | Adds extensive coverage for encoding injectivity, dispatch, buffering, caching, and cleanup. |
| tests/units/test_app.py | Covers hash-cache cleanup through application compilation paths. |
Reviews (19): Last reviewed commit: "test: isolate the annotation-mismatch te..." | Re-trigger Greptile
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
The deterministic hash exists for exactly one purpose: giving an
auto-memoized component a stable, non-colliding export name. It lived in
`component.py` as `Component._get_component_hash` and
`Component._compute_memo_tag`, but nothing outside `memo.py` ever called
either, and neither is a property of a component the way `render()` or
`_get_imports()` is.
Move the encoder and both entry points into `memo.py` as
`component_hash(component, *, recursive=...)` and `memo_tag(component)`,
next to the `create_passthrough_component_memo` call site, and drop the two
methods from `Component`. The `shallow` flag becomes `recursive`, named for
what it means at the call site: a snapshot memo body carries its whole
subtree, a passthrough body carries a `{children}` hole. Also drops the
unused `_hash_str` helper.
The own-node artifact set was missing `add_custom_code`: `_get_custom_code`
was hashed but the classmethod extension point was not, while the recursive
side picked it up through `_get_all_custom_code`. Two passthrough bodies
that rendered identically and differed only in the module-level code they
emit therefore shared one memo module, and one of the two code blocks was
dropped. Fed explicitly now, with a regression test.
Compile wall time is unchanged; this is a structural change plus the
collision fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The encoding caches that speed up memo naming were module globals with no teardown. The two value caches are capped, but the dataclass field-layout cache is keyed by type and was uncapped -- and a dataclass defined inside a function body is a fresh class object on every call, so hashing one pinned a class per compile for the life of the process. Confirmed reachable: 50 dynamically created dataclasses survived a gc.collect(). Capping that cache would be the wrong fix. It bounds retention without removing it, and once the cap is hit every dataclass encode falls back to `dataclasses.fields()` plus re-encoding field names per instance -- a silent cliff on the hot path, for a cache whose real-world population is two entries (`VarData` and `ImportVar`, stable across repeated compiles). Every component auto-memoization will ever name is named during compilation, so drop all three caches when it finishes, alongside the existing `GLOBAL_CACHE.clear()` in the same post-compile block. Digests are unchanged and compile wall time is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
…ffer Review of the naming hash turned up two more gaps of the same kind as the `add_custom_code` one: - `_get_dynamic_imports` is emitted into the memo body by `compile_experimental_component_memo` but was never hashed, so two components differing only there shared a module and one of their two import statements was dropped. - `memo_tag` identified a class by `__qualname__` alone, so two modules each defining `class Card` with the same rendered output produced the same tag -- exactly what the qualname prefix exists to prevent. The defining module now reaches the digest rather than the prefix, which keeps the discrimination without stretching every generated module filename by a dotted module path. Both are covered by regression tests that fail without the fix. Also make the encoder's buffer bound real: the flush check ran only after a container's whole loop, so one flat 2 MB dict buffered 2 MB before the first flush. Checking per item holds it at the intended 64 KiB and costs nothing measurable -- the encoder is still 1.7-2.0x the old one and every digest is byte-identical to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
`clear_hash_caches()` was called from `App.__call__`, which only the ASGI path reaches. `reflex export` and `reflex compile` get to a compile through `prerequisites.get_compiled_app` -> `App._compile` and never touch `__call__`, so those paths never released anything. Move the call into `App._compile` -- the single funnel every compile goes through -- inside a `finally`, so a failed compile does not leave the caches behind either. Covered by a test that fails under the old placement, on both the success and the exception path. Also add the root `news/` fragment: this PR now touches `reflex/`, so the changelog check requires one for the main package too. Corrects the reflex-base performance fragment, which claimed digests were unchanged -- true of the encoder rewrite alone, but later commits deliberately folded the defining module and dynamic imports into the hash, so generated memo module names do change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
Moving the naming hash into `memo.py` changed the source that `reflex/experimental/memo.pyi` is generated from, so its recorded hash went stale and the pre-commit check failed. I ran `make_pyi.py` after the first commit but not after the move. `pre-commit run --all-files` now passes all seven hooks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
The PR base was two days stale and GitHub Actions had not spawned any workflow run for the previous head, so bringing main in both revalidates the change against current main and produces a real commit for CI. Conflict: `pyi_hashes.json` — both sides changed the generated hash for `reflex/experimental/memo.pyi`. Resolved with the tooling rather than by hand: took main's value, then regenerated. The first `make_pyi.py` run was short-circuited by a stale `.pyi_generator_last_run`; after clearing it the generator produced a third value, distinct from either side, since the merged `memo.py` combines main's changes with this branch's. `pre-commit run --all-files` passes all seven hooks against the merged tree, and the 652 tests covering the touched areas (test_memo, test_component, compiler, test_app, benchmarks) all pass. The remaining unit-test failures are confined to `tests/units/reflex_cli/**` and `test_processes.py`, which this branch does not touch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
…ision Incorporates the ideas from #6804 into the memo-name hashing rework. The encoder no longer walks an isinstance ladder for every value whose exact type has no inline fast path. It resolves an encoder once per type and reaches it through a memoized table, so vars, components, enums and dataclasses each pay the ladder once per compile instead of once per value. That also collapses the two parallel ladders the previous version carried -- an exact-type one and an isinstance one -- into a single encoder per type. Fixes a fifth naming collision, found by #6804: the dataclass branch sat ahead of the component branch, so a component that also inherits a dataclass encoded as that mixin's field list. Every component built on MarkdownComponentMap does -- rx.text, rx.heading and friends -- and the mixin declares no fields, so all of them encoded to the same nine bytes. Reachable through app-wrap components, which the hash feeds in as components rather than as rendered dicts. The ImportVar encoding cache is no longer hard-coded to ImportVar: any frozen dataclass declaring only str/bool/None fields is cached by value. Numbers are excluded because equality has to imply an identical encoding for a value-keyed cache to be sound, and True == 1 == 1.0 while all three encode differently. Reading a class's annotations is the expensive part, so that per-type verdict outlives a compile in a WeakKeyDictionary, which still lets a dataclass defined in a function body be collected. Cached encodings are bounded in size as well as in count. Verified byte-identical: every memo tag generated while compiling three benchmark pages is unchanged, and an A/B of the two encoders on the values those compiles hash gives matching digests at 1.00-1.02x the speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
…orate-6804-ysaagl # Conflicts: # packages/reflex-base/src/reflex_base/components/component.py # pyi_hashes.json
Fragments are for downstream users; the narrative is a click away on the PR. The bugfix one becomes a bulleted list of what the memo name now accounts for instead of a paragraph per collision. Verified the list renders correctly through the release tooling's own towncrier invocation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
The hash is not a property of memoization -- it digests components, vars and rendered data under a self-delimiting encoding, and auto-memoization is just its only caller today. Moved to reflex_base/utils/deterministic_hash.py with the tests alongside it. The private _deterministic_hash/_update_deterministic_hash pair becomes one public variadic deterministic_hash(*values), which is what the two call sites wanted: component_hash now reads as the render plus the artifacts that identify a memo body, and _update_component_artifacts_hash becomes _component_artifacts, a generator that yields them instead of threading a hasher and buffer through. One shared buffer still covers the whole digest. Nothing else under utils imports components at runtime, so the two isinstance checks that need Var and BaseComponent import them inside _resolve_hash_encoder -- once per type, so it never shows up in a profile -- and the module now imports standalone without pulling in the component system. Digests are unchanged: memo tags across three benchmark pages are byte-identical to before the move, and an A/B of the encoder before and after runs at parity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The PR touches reflex/app.py, so the root package needs a fragment too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
Walking the ImportVar lists was the largest single item in the memo-name encoding, and almost all of it was redundant. An import only reaches a memo body through the local name it binds, and every name a body references is already in its render, hooks or custom code: Reflex aliases each tag to a globally unique binding (Trigger -> RadixAccordionTrigger), and validate_imports rejects one name bound from two libraries. So bodies that render alike reference the same names, and the library names are what pin where each name comes from. Encoding drops 11-19% depending on the page (13.06 -> 11.55 ms on a memoization-heavy one). Distinct tag counts across three benchmark pages are unchanged -- 61, 9 and 2 -- so no page gains a collision from the narrower digest. What this deliberately stops distinguishing, documented on the function: two bodies binding the same name from the same library to a different export (X as N vs Y as N) or in a different form (default vs named). Both need one library to export two things a component aliases to one name. Generated memo module names change again, for the same reason as the rest of this branch: nothing outside the compiled output refers to them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Two injectivity holes in the encoder, both raised in review and both confirmed: - Two dataclasses with the same field names and values encoded identically, so Alpha(a="x") and Beta(a="x") shared a digest. The defining class now goes into the cached layout header, which is built once per type. - enum members encoded as str(value), which for an IntEnum is just its integer, so Level.ONE and 1 shared a digest. Enums now get their own tag and encode their qualified member name. Both were present before this branch; the encoder claims injectivity, so they belong with the rest of the collision fixes. The parametrized case that claimed to cover distinct dataclass types compared two instances of one type and could not have caught the first; it now uses two types. The synthesized-dataclass test asserted a runtime-built class hashed equal to the class it copied its fields from, which only held while class identity was absent from the digest. Also trims the narrative from comments and docstrings across the branch, leaving what the code needs and moving the reasoning to the PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
Nothing exercised a single leaf bigger than _HASH_BUFFER_FLUSH_SIZE. A leaf is appended whole, so the buffer holds all of it before the first flush can run: a LiteralStringVar of 3x the flush size peaks at 196,621 B against a 65,536 B threshold. Behaviour is correct, it just had no test. Adds that case, plus a parametrized check that the digest is identical for flush sizes from 1 byte to 1 GiB, which pins the invariant that chunking only moves bytes into the hasher. The second one fails if a flush stops clearing the buffer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
| # paths reach it via ``get_compiled_app`` and never touch | ||
| # ``App.__call__`` -- and the ``finally`` keeps a failed compile | ||
| # from leaving them behind. | ||
| clear_hash_caches() |
There was a problem hiding this comment.
Clear the caches in a finally inside compiler.compile_app instead, and leave _compile as it was.
compiler.compile_app has exactly one caller, so the guarantee is identical. It also puts the cache lifetime next to the only consumer of those caches, and the 20-line restructure of the telemetry control flow here — which exists only to host this one call — goes away.
There was a problem hiding this comment.
Agreed the re-indent is ugly — the whole method body moved a level in to host one line. I've left it as is for one concrete reason, but I'm happy to be overruled.
The guarantee is identical either way, so this is placement. What decides it for me is the test. test_compile_releases_hash_caches patches reflex.compiler.compiler.compile_app with a stub that seeds a cache entry and optionally raises, then asserts App._compile() leaves the caches empty on both paths. Move the finally inside compile_app and that patch replaces the code under test, so the test would pass while exercising nothing. Testing it in the new position means either driving a real compile_app in a unit test, or patching something one level further in (_apply_decorated_pages, the plugin pipeline) — a heavier and more coupled test than the one-line stub it replaces.
One correction on the premise: compile_app has two call sites, not one — app.py:1665 on the no-telemetry path and app.py:1676 inside the with ctx: block. That doesn't undercut your suggestion, since a finally inside compile_app covers both, but it is why the finally here had to wrap the whole method rather than one call.
If you'd still prefer it in compile_app, say so and I'll move it and rework the test to drive the real function through the _should_compile() short-circuit — that path returns early inside compile_app, so it exercises the release without a full compile.
Generated by Claude Code
FarhanAliRaza
left a comment
There was a problem hiding this comment.
I tested this in a real app against the merge base (3573364f8).
I built an app with custom components forced to MemoizationDisposition.ALWAYS. One varies its add_custom_code per instance. One varies its ImportVar per instance under a fixed library key. Both render identical JSX in either case. I compiled the app on both branches and diffed the generated memo module.
The PR's own fix is confirmed. The base emits only const PROBE_B and one memo. This head emits PROBE_A and PROBE_B and two distinct memo names.
The import case moves the other way. The base emits two memos and both stylesheet imports. This head emits one memo, and one stylesheet import is gone from the compiled module with no error.
I also ran a valid app in dev mode. State events, rx.cond, rx.foreach, two-way rx.input binding, rx.upload, and two ALWAYS-memoized probes all work. Each probe compiles to its own memo module. The browser console reports no errors or warnings after interaction.
Test runs are clean: 295 unit tests in test_deterministic_hash.py, test_memo.py and test_app.py, ruff check, ruff format --check, and pyright.
I measured the hashing speedup with 201 component_hash calls on a 201-component page, median of 9 runs.
Five requested changes are inline.
Two review findings, both reproduced.
Hashing only import library names dropped ImportVar payloads from the digest.
Icon._get_imports builds a per-instance package_path and alias, and a tagless
ImportVar defaults to render=True so compile_imports emits it as a side-effect
import ("lucide-react/light.css"). Two bodies differing only there rendered
identically, shared a memo tag, and one body's import never reached the
compiled module. Restores the imports dict; the narrowing was measured again
at ~4% of encoding, not the 23% an earlier harness suggested.
_encode_hash_cached_dataclass keyed the value cache on instances whose
hashability comes from their declared field types, so a frozen dataclass
declaring `name: str` while holding a list raised TypeError where the previous
hasher returned a digest. It now falls back to encoding the fields directly.
Also corrects the module docstring: numbers carry a type tag but no length
prefix, so the previous wording overstated the encoding.
Re-measured against main: encoding 1.31x on a memoization-heavy page and 1.17x
on _stateful_page, with the whole component_hash at parity. Fragment updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Its well-typed _KeyedProbe instances take the cached-dataclass path, so it left entries in _hash_dataclass_encodings for whatever ran next. Every other cache-touching test in the file already takes clean_hash_caches; this one now does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
Type of change
New Feature Submission
Changes To Core Features
Description
The deterministic hash exists for one purpose: giving an auto-memoized component a stable, non-colliding export name. Started as a profiling question about it, and grew into three related pieces.
1. The encoding is faster — measured against current
main.component_hash_stateful_page_complicated_pageMin-of-11 on a live compile, caches cleared per run,
main's encoder and artifact set reproduced in the same process. The columns differ because encoding is only ~13% ofcomponent_hash— the rest isrender()and the_get_all_*walks, which this PR does not touch. @FarhanAliRaza independently measured 1.18–1.23x on a 201-component page, which agrees. CodSpeed reports the end-to-end effect on each push; see its comment rather than a number pasted here.Earlier revisions of this description claimed 2.3–2.6x. That came from a corpus replayed several passes per timed run, so every pass after the first ran on warm caches and already-rendered components. It also predated #7012, which cut
ImportVarencodes on my benchmark page from 1182 to 854 and removed much of the redundancy the caching exploits.What makes it faster: the encoder writes into a
bytearrayflushed in 64 KiB chunks instead of onehasher.update()per node, resolves an encoder once per type instead of walking anisinstanceladder per value, caches each dataclass type's field layout with pre-encoded names, and caches the encoded form of short strings and of frozen dataclasses whose declared fields are allstr/bool/None(ImportVarabove all — 854 encodes across 22 distinct values on one page).2. Moved into
reflex_base/utils/deterministic_hash.py.Nothing outside
memo.pycalledComponent._get_component_hashorComponent._compute_memo_tag, and neither is a property of a component the wayrender()is. The hash itself is not a property of memoization either — it digests components, vars and rendered data, and auto-memoization is its only caller today — so it now lives inutilswith its tests beside it.memo.pykeepscomponent_hash,memo_tagand_component_artifacts.shallowbecamerecursive, named for what it means there. Dead_hash_strhelper dropped.3. Seven memo-name collisions, each dropping compiled output.
The hashed artifact set has to match what
compile_experimental_component_memoactually puts in the memo body, and the encoding has to be injective. Neither held:add_custom_codenot hashed (only_get_custom_code)_get_dynamic_importsnot hashed at all__qualname__aloneclass Cardwith identical output collided@dataclassdefined in a function body is a fresh class per call; 50 stayed pinned through agc.collect()Alpha(a="x")andBeta(a="x")— distinct types, identical digeststr(value)IntEnum.ONEand1— identical digest, since 3.11 madeIntEnum.__str__the integerThe fifth is the broad one. Every component built on
MarkdownComponentMap—rx.text,rx.headingand friends — inherits a dataclass that declares no fields, so on the old branch order_deterministic_hash(rx.text("a")) == _deterministic_hash(rx.text("bbbbb")): all of them encoded to the same nine bytes. Reachable throughcomponent_hashvia app-wrap components, which the hash feeds in as components rather than as rendered dicts.The last two predate this branch but live in code it rewrites, and the module claims injectivity, so they belong here. Both fixes are per-type and cached: the defining class goes into the dataclass layout header, and enums get their own tag plus qualified member name.
The module now reaches the digest rather than the tag prefix —
format_state_nameonly maps dots to__, and those names become filenames, so a dotted path in the prefix would stretch every generated memo module name. The caches are released fromApp._compilein afinally: that's the single funnel every compile goes through, sincereflex exportandreflex compilereach it viaget_compiled_appand never touchApp.__call__.Credit
The encoder table, the branch-order bug and the idea of generalizing the
ImportVarcache to frozen dataclasses all come from @benedikt-bartscher's #6804, which landed on the same code independently. Two things are deliberately taken differently there:id(). On a memoization-heavy page the hash sees 854ImportVarencodes across 542 distinct ids but only 22 distinct values, so identity keying hits ~37% where value keying hits 98%. Measured per visit: ~220 ns for a value-keyed hit, ~160 ns for an id-keyed one, ~1930 ns to re-encode from scratch._IMMUTABLE_FIELD_TYPESin improve compile perf #6804 includesint/float, which is sound under id keying but not under value keying — improve compile perf #6804 has its own test for exactly this (Truevs1), and reading declared annotations is what keeps that test passing here.A narrowing that was tried and reverted
An earlier revision hashed only import library names, on the argument that a body reaches an import through the local name it binds, and every name it references is already in its render. @FarhanAliRaza showed that is wrong: a tagless
ImportVardefaults torender=Trueandcompile_importsemits it as a side-effect import (lucide-react/light.css), andIcon._get_importsbuilds a per-instancepackage_pathandaliasunder one library key. Two such bodies rendered identically, shared a tag, and one body's import never reached the compiled module.Reverted in 66a360f. Re-measuring on a live compile put the narrowing at ~4% of encoding, not the 23% an earlier harness of mine reported — that one charged its own projection wrapper to the full-dict side. Roughly 0.4 ms against a ~112 ms
component_hash, so there was nothing to trade a dropped import for.Note for reviewers
Generated memo module names change, for two independent reasons: item 3 deliberately folds new material into the digest, and merging
mainbrought in #7012, which changes the import dicts the hash consumes. Nothing outside the compiled output refers to them, and no test pins them.The
mainmerge had two conflicts:component.py(this PR deletes the hashing block,mainaddedPROHIBITED_LIBRARY_IMPORTSin the same region — both intents kept) andpyi_hashes.json(regenerated withmake_pyi.pyrather than picking a side).Tests
In
tests/units/reflex_base/utils/test_deterministic_hash.py(with the code under test),tests/units/components/test_memo.pyandtests/units/test_app.py. Each collision fix has a regression test that was mutation-checked — reverting the fix fails the test.Four tests passed for the wrong reason and were rewritten: two probe classes with different qualnames, where class identity alone separated them; two probes with different metaclasses, so the encoder-table test could not see the poisoning it was meant to catch; a parametrized case labelled "dataclasses of the same shape but different types" that compared two instances of one type; and an assertion about a Var's type tag that held only because
LiteralStringVar._js_expris the quoted form.Also covers encoding injectivity, cache-key correctness by value, a frozen dataclass holding a value its annotation does not admit (unhashable, so it cannot key the cache), strings and dataclass encodings past the cache limits, a single leaf larger than the flush buffer (a
LiteralStringVarat 3x the flush size peaks at 196,621 B against a 65,536 B threshold — a leaf is appended whole, so it is the one value the flush size cannot bound), digest equality across flush sizes from 1 byte to 1 GiB, dataclasses that can still change, runtime-synthesized dataclass subclasses (MutableProxycopies__dataclass_fields__without__dataclass_params__), and aclean_hash_cachesfixture so cache-state tests are order-independent underpytest-randomly.Not in this PR
component_hashis still ~45% of compile wall time on a foreach/cond-heavy page (~112 ms of ~250 ms on one page here), and encoding is only ~13% of that. The rest is per-node artifact gathering plusrender()over snapshot subtrees, and reusing it needs the page walk to descend into snapshot subtrees with the collector sealed off — the_memoize_structural_childmachinery Implement client state with useClientState hook #6936 is adding. Should build on that rather than race it. (Fusing the five_get_all_*traversals into one was tried and measured ~2% slower: they already share cached per-node results, so the aggregation was never the cost.)RadixThemesComponent._get_app_wrap_componentsbuilds a fresh provider per call — 114 values reaching the hash across 113 distinct objects on one page, ~30% of encoder time, all of it re-rendering equivalent objects. Filed as Memoize RadixThemesColorModeProvider instead of building a fresh instance per component #7013.compile_experimental_component_memopulls the whole subtree's imports into the body (utils.py:409) while the hash's own-node branch feeds onlycomponent._get_imports(). Two passthrough memos with identical roots and differing descendants therefore share a tag while their emitted import blocks differ. Confirmed at the hash level, no end-to-end repro yet; either the hash should match the compile there orutils.py:409should narrow to the root. Belongs in its own change.GLOBAL_CACHE.clear()has the same ASGI-only gap as the old cache-release site, soreflex exportnever frees the var cache either. Pre-existing and unrelated; belongs in its own commit.🤖 Generated with Claude Code
https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c