Release Overview

WebFluid Documentation

This is the first beta of WebFluid. Beta 1 adds no new batteries — instead it takes everything the two alphas grew and rebuilds it properly: the core and every extension went through a SOLID refactor, the request path was measured and the slow parts removed, and the framework finally has a test suite standing behind it. A handful of names moved on the way, so read the breaking changes before you upgrade. The shape of the framework is settled now; pinning your version is still the safe habit while the beta runs.

1.0.0b1 Beta August 01, 2026
View repository

New Features

+

A measured request path

The pipeline no longer runs on Starlette's BaseHTTPMiddleware, Jinja stops stat-ing every template outside debug mode, and url_for resolves through a name index. Rendering a page got roughly 3.8x faster, current_user went from seven queries to three, and the require_admin gate from seven to one.

+

An async translation API

babel.agettext, angettext, apgettext and anpgettext read uncached keys through an async session instead of blocking the loop. The synchronous API is unchanged, and the template callables deliberately stay synchronous.

+

Tests and budgets

136 tests cover the lifecycle contracts, the request pipeline, version resolution, additive enabling, the events socket against a real uvicorn, the security gates' query counts and both translation paths. benchmarks/bench.py pins import, render and request time to budgets so the numbers above cannot quietly regress.

+

Rows that outlive their executor

Both session factories now run with expire_on_commit=False. A row read through db.executor(...) stays usable after the block closes, exactly like the identical read through db.async_executor(...) always did.

+

Additives that pull their dependencies

Additive.install() now resolves the additives a manifest requires and pulls the missing ones from the Ocean before extracting files and installing packages. wf ocean install runs that step for everything it fetched.

+

Bearer grants next to session gates

requirement_or_grant and requirement_and_grant let a guarded route accept either a logged-in session or a JWT carrying the named grant, so one endpoint can serve a browser and a machine without a second implementation.

+

Surfaces that follow their theme

The bundled stylesheets no longer bottom out in a hardcoded navy. Page and error backgrounds, the nav, footer, cards and traceback frames derive from four surface tokens on :root, so a theme that is not blue-grey no longer ends in a blue-grey lower half.

+

One seam for forks

webfluid.core.identity holds the framework id, name, abbreviation, urls and hub endpoints. Everything that used to hardcode one of them — the environment prefix, the logger names, the entry-point group, the base template, the static mount — now derives from that single module.

Fix Fixes

Additive request hooks

Additive.before_request and Additive.after_request raised AttributeError. The three lifecycle implementations had drifted apart during the refactor and now share one Phase type.

The events socket

/ws/events closed immediately under uvicorn because the socket handler was spawned as a task while the endpoint returned. It is awaited now, and a test drives it against a real server.

Version requirements

check_required_version compared only the numeric release parts, so >1.0.0a1 did not match 1.0.0a2 and ==1.0.0a1 did match 1.0.0b1. Version handling runs on packaging now.

Startup with Babel enabled

Every application with EXT_BABEL died in its startup hook once the relationships were tightened, because the bulk catalog load read a related column as an attribute. It joins and eager-loads that key explicitly, which also replaced an EXISTS subquery with a plain join.

Translated model properties

Babel.domain_context wrapped every decorated callable in an async wrapper, so a @property over it yielded a coroutine on attribute access. It branches on the wrapped function again, the way it did in alpha 2.

Console output under wf run

The application's output was streamed through a text-mode pipe, so a progress bar that redraws itself in place arrived as one line per frame. The pipe is read as bytes now, terminal geometry and encoding are handed to the child, and the reader runs to EOF so nothing is lost between the stop message and the last shutdown hook.

Tailwind without themes

The frontend loader probes for the stylesheet that matches the switch you set, so a surface shipping only tailwind_no_themes.css keeps its link. Frontend.include no longer raises when Tailwind is enabled on an uncovered frontend, and cover_additive no longer corrupts its own prefix when it is called twice.

Server-side theme switching

fluid.set_theme() validated the name as if it were registering a new theme and rejected every theme that actually existed. It now checks the opposite direction and selects your registered themes.

Migrations that mind your app

The generated Alembic environment skips the Additives that are disabled for the app you are migrating, so autogenerated revisions stop collecting foreign tables. It also resolves *_FILE config values the same way wf run does, so file-backed secrets reach the migration.

Smaller sharp edges

Manifest.check_requirements emptied its own requirement dictionary, so a second check reported success. try_import re-raised when a parent package was missing, which broke projects generated with --skip-defaults. And clear_logs deleted the log file it was writing to.

Bug Known issues

!

One translation key per source string

The translation key table is unique on the source string alone, not on the pair of string and domain. If two domains declare the same source string, the second one writes its messages against the first domain's key and never finds them again. Keep your keys distinct across domains — the framework's own catalog uses screaming-snake keys like ERROR_TITLE for exactly that reason.

!

Rate limits answer with a 500

The rate limit handler is installed without the limiter it reads its response headers from, so a request that actually exceeds a limit raises inside the handler and comes back as a 500 instead of a 429. Rate limiting itself works — the request is rejected, just with the wrong status. Until this is released, catch RateLimitExceeded yourself if the status code matters to your clients.

!

SECURITY_CSRF_COOKIE_NAME is ignored

The value is read from your config and then never used — both sides of the double-submit check hardcode csrf_token. Setting a different name does not break anything, it simply has no effect, so leave it at the default for now.

!

An empty stylesheet link

When WF_TAILWIND is on but a surface has no Tailwind entry point of its own, the frontend() helper still emits a <link rel="stylesheet"> with an empty href, which makes the browser fetch the page again as a stylesheet. Keep a raw stylesheet in the surface's static/css, or leave WF_TAILWIND off for apps that do not use it.

!

wf migrate init crashes

A NameError, every time. The helper class it builds internally shadows project_root in its own body before Python can resolve it. No workaround besides patching migrate.py — fixed next release.

!

Migrate template selection

wf migrate init chooses the single- or multi-database template from SQLALCHEMY_BINDS in your config. Binds attached at runtime through Model.set_bind are not detected and need a manual template.

!

Event backpressure

Broadcasts use a bounded per-listener buffer sized by EVENTS_EVENT_QUEUE_SIZE. A consumer that falls behind still loses its oldest events — it is logged as a warning now instead of happening silently, but delivery stays best-effort either way.

!

Hooks live in mix(), not in the lifespan

Startup and shutdown hooks run from fluid.mix() rather than through the ASGI lifespan protocol, so they do not run when an external ASGI server imports and serves your app. Run your apps through wf run for now.

!

Signals need a running loop

events.create_signal and events.event have to be called from inside the running event loop, because the broadcaster loop is created eagerly. Register them from an enable hook or a startup hook, not at import time.

!

Uncached translations still block

A key you explicitly opted out of the cache is read through a synchronous session on the gettext path. agettext offers a non-blocking alternative, but the callables installed into Jinja stay synchronous on purpose.

!

HMR relies on a proxied websocket that can die

Hot reload across the main app and multiple Additive frontends rides on the websocket proxy in front of the shared dev server. That connection can drop on its own — a Vite restart is the usual trigger — and when it does, the affected frontend simply stops picking up changes. A browser refresh re-establishes the proxy immediately; there is no need to restart wf run for it.

Break Breaking Changes

Relationships no longer load themselves

Every model relationship in the security and babel extensions was audited against how the framework actually reads it. Only User.totp_secret and User.webauthn_credentials stay eager; everything else — User.roles, Role.permissions, User.identities, User.backup_codes and every reverse side — is lazy="raise_on_sql". Reading user.roles in a handler or template is the most likely thing to break; load it with selectinload() or a separate query. The cascades are unchanged.

Renamed on the Fluid instance

app_root is now project_root (the old name still works and warns). app_static and framework_static became static_files, state.limiter became limit, and static_prefixes is an object with add() and matches() instead of a set. asgi_app is gone entirely — PROXY_FIX adds a normal middleware now.

Rendering a string

is_string is gone. fluid.render() and additive.render() no longer inspect it, so passing it now reaches the template as an ordinary variable while your source string is looked up as a template name. Use fluid.render_string(source, ...) instead.

Sending mail

mail.send_async(...) is now mail.asend(...), matching the a-prefix every other async pair in the framework uses. The synchronous send and both client context managers are unchanged.

Versions are packaging versions

FluidVersion and AdditiveVersion are gone; webfluid.version() and Additive.version hand you a packaging.version.Version subclass built from Version(*parts). Additive.version is a plain attribute now, not a property, and check_required_version(..., "wf") became "framework" — which is also the default.

The lifecycle API

HookPhase.add_hook and RequestPhase.add_processor are one Phase.add, and Lifecycle.run_hooks() split into run_startup(), run_shutdown(), run_before() and run_after(). The decorators on your app and your Additives are unchanged.

Config building

core.config.ConfigMeta is gone and build_config() returns a plain dict; configs are merged as dictionaries. DefaultConfig is the single source of truth for every default, so fluid.config["KEY"] is always populated and consumers no longer repeat defaults inline.

Constants and endpoints

WF_STATIC is FRAMEWORK_STATIC, WF_OCEAN and OCEAN_AUTH are HUB_API and HUB_AUTH, and FRAMEWORK_ROOT moved to core.identity. The AUTH_API environment variable is now OCEAN_AUTH. Internally, core.processing.error.add_exception_handler became install_error_handler and core.fluid.middleware.http became middleware.request.

The app factory has a name

The migration environment looks for prepare_fluid() in your main.py (falling back to a module level fluid), and that is what wf create project generates. If you carried an alpha project with a create_app() factory, rename it.

Reaching request-scoped data

FluidContext.get_ctx_data is gone. Use FluidContext.cached_or(key, factory) for per-request memoisation, or read the extension's own config.