Skip to content

ENG-9917 fix: run plugin post_compile hooks at most once per app instance - #6733

Open
FarhanAliRaza wants to merge 4 commits into
reflex-dev:mainfrom
FarhanAliRaza:farhan/eng-9917-post-compile-once-per-app
Open

ENG-9917 fix: run plugin post_compile hooks at most once per app instance#6733
FarhanAliRaza wants to merge 4 commits into
reflex-dev:mainfrom
FarhanAliRaza:farhan/eng-9917-post-compile-once-per-app

Conversation

@FarhanAliRaza

Copy link
Copy Markdown
Contributor

Fixes ENG-9917 at the framework level.

Problem

App.__call__ runs every plugin's post_compile(app=self) each time the ASGI app is constructed. post_compile hooks mutate the app — add_middleware, mounting routes — so constructing the ASGI app twice on the same app instance (a test harness rebuilding it, repeated app() calls) re-applies those mutations. The concrete instance that surfaced this: enterprise AuthPlugin installing a second AuthMiddleware, gating every event twice. But the bug class belongs to the lifecycle, not to any one plugin — every app-mutating post_compile hook is affected.

Fix

Latch the hook loop with a per-app-instance flag (_plugins_post_compiled), so post_compile runs at most once per app instance. The flag is set after the loop completes, so a hook that raises (e.g. a plugin rejecting a misconfigured app) is retried on the next construction instead of being silently skipped. The at-most-once guarantee is documented on Plugin.post_compile so plugin authors don't hand-roll idempotency guards.

Backend-only processes (REFLEX_SKIP_COMPILE) are unaffected: they construct a fresh App per process, and the flag starts false. Dev hot reload restarts the worker process (fresh App), so hooks still re-run there as before.

Tests

  • test_call_app_runs_plugin_post_compile_once — regression test, failed before the fix (hook ran twice).
  • test_call_app_retries_post_compile_after_failure — pins the not-latched-on-failure semantics.

Full unit suite passes (6619, coverage 77.97%), ruff + pyright clean.

App.__call__ runs whenever the ASGI app is constructed, and it re-ran
every plugin's post_compile each time. post_compile hooks mutate the app
(add_middleware, mounting routes), so a second construction on the same
app instance (a test harness rebuilding the ASGI app) re-applied those
mutations — e.g. a middleware installed twice gates every event twice.

Latch the hook loop per app instance, mirroring the once-per-process
guarantees elsewhere in the compile path. The latch is set after the
loop completes so a failed hook is retried on the next construction
rather than silently skipped. The guarantee is documented on
Plugin.post_compile so plugins don't need their own idempotency guards.

Fixes ENG-9917.
@FarhanAliRaza
FarhanAliRaza requested a review from a team as a code owner July 10, 2026 18:50
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes plugin post-compile hooks to run once per app instance. The main changes are:

  • Tracks completed plugin hooks by plugin identity.
  • Caches the assembled ASGI app after a successful build.
  • Documents the new Plugin.post_compile lifecycle contract.
  • Adds tests for repeated calls, retry after failure, partial failure, unhashable plugins, and ASGI caching.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
reflex/app.py Adds per-plugin hook tracking and caches ASGI assembly after the first successful construction.
packages/reflex-base/src/reflex_base/plugins/base.py Documents that post_compile runs at most once per app instance.
tests/units/test_app.py Adds tests for repeated app construction, retry behavior, partial failure handling, unhashable plugins, and cached ASGI app identity.
news/6733.bugfix.md Adds a release note for avoiding repeated app mutations and caching ASGI assembly.
packages/reflex-base/news/6733.bugfix.md Adds a package release note for the Plugin.post_compile lifecycle update.

Reviews (3): Last reviewed commit: "fix: track completed post_compile hooks ..." | Re-trigger Greptile

Comment thread reflex/app.py Outdated
Comment thread reflex/app.py Outdated
@codspeed-hq

codspeed-hq Bot commented Jul 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing FarhanAliRaza:farhan/eng-9917-post-compile-once-per-app (f315ecf) with main (052d891)

Open in CodSpeed

Footnotes

  1. 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 337341739e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread reflex/app.py Outdated
Comment on lines +739 to +742
if not self._plugins_post_compiled:
for plugin in config.plugins:
plugin.post_compile(app=self)
self._plugins_post_compiled = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track completed post_compile hooks individually

Because the latch is only set after the whole plugin loop succeeds, a retry after a later plugin raises will start from the first plugin again. If an earlier plugin already mutated the app (for example by adding middleware or routes) and a subsequent plugin fails once, the next app() call duplicates the earlier mutation, so the new at-most-once guarantee still fails in the explicit retry-after-failure path.

Useful? React with 👍 / 👎.

Gating only the plugin post_compile loop left the same repeat-mutation
bug elsewhere in App.__call__: the compiled-frontend mount was appended
to the persistent self._api on every construction, and a Starlette
api_transformer was re-mounted with an extra CORS layer each call. A
single boolean latch also re-ran already-succeeded hooks when a later
plugin's hook raised, reinstalling their middleware on retry.

Extract the assembly steps (plugin hooks, frontend mount, transformer
wiring, top-level app) into _assemble_asgi_app, cache its result per
instance, and track completed hooks per plugin so a failed hook is
retried without re-running the ones that already mutated the app.
_compile intentionally still runs on every call: forked prod workers
re-enter __call__ to re-evaluate stateful pages backend-side.
@FarhanAliRaza FarhanAliRaza changed the title fix: run plugin post_compile hooks at most once per app instance ENG-9917 fix: run plugin post_compile hooks at most once per app instance Jul 10, 2026
Comment thread reflex/app.py
Comment on lines +737 to +738
# Assembly runs at most once per app instance; _compile stays per-call.
if self._cached_asgi_app is None:

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 Cached plugins stay skipped After the first successful app() call, this gate returns the cached ASGI app and never enters _assemble_asgi_app() again. If get_config().plugins is changed on the same App instance between ASGI constructions, the new plugin is never inspected, so its post_compile hook cannot add its middleware or routes. The per-plugin set only helps when assembly runs again; this cache blocks that path entirely.

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's potentially problematic that we allow a recompile on the same instance, but don't re-run the hooks on the same instance.

If the second compile adds a page, and a plugin's post_compile does something with pages, then the newly added page will be compiled into the app, but won't be affected by the plugin.

Is there a legit use case where this hits? or does it just happen in testing?

My thought would be if we run _compile, then we should run post_compile hooks.

Maybe instead we just need to reset things on the app instance, like the pages/routes, middleware, lifespan tasks, etc to make it safe to re-__call__ the app instance and essentially rebuild it. (I think properly speaking we should be attaching these to the returned asgi and maybe not mutating the App at all, but that can come later)

Real plugins are dataclasses (eq=True sets __hash__ = None), so storing
them in a set raised TypeError: unhashable type on every app that uses
the default TailwindV4Plugin. Key the completed-hook record by id()
instead, keeping the plugin as the value so its id cannot be recycled;
identity is also the right semantics, since equal-but-distinct plugin
instances must each run their hook.

Also add the reflex-base news fragment required by the per-package
changelog check for the Plugin.post_compile docstring change.
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.

2 participants