Skip to content

ruflet rails erb native d11504 - #25

Merged
AdamMusa merged 32 commits into
mainfrom
claude/ruflet-rails-erb-native-d11504
Aug 12, 2026
Merged

ruflet rails erb native d11504#25
AdamMusa merged 32 commits into
mainfrom
claude/ruflet-rails-erb-native-d11504

Conversation

@AdamMusa

@AdamMusa AdamMusa commented Aug 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

AdamMusa added 30 commits July 31, 2026 00:44
Rails ERB views compile into real native controls, so a Rails app can drive a
native UI without a Ruflet app file or a WebView. The feature previously lived
only in an uncommitted worktree; this lands it in the package.

  html_dsl/parser, styles, transformer   markup -> control tree
  html_dsl/html_app                      screens, navigation, services
  html_dsl/rack_fetcher                  in-process Rails dispatch, no socket
  html_dsl/view_helpers                  112 ERB helpers

Two fixes for how an in-place re-render costs:

  control_diff  patch only the props that changed instead of replacing the
                body. A counter tap went from 653 bytes and 11 controls to 53
                bytes and one prop, and the client no longer rebuilds the
                subtree (which was losing scroll position).

  render_native Post/Redirect/Get is for browsers with a reload button. The
                native client has neither, so the redirect doubled the work.
                Actions now answer the native session directly: two Rails
                cycles per tap became one.

Service calls that wait on a person -- permission prompts, file pickers, save
sheets -- no longer use the 10s default, which expired while the dialog was
still on screen ("execution expired"). 30s covers answering a dialog without
turning a broken call into a long hang.

save_file's src-bytes arrives from an HTML attribute as text; force it to
binary so msgpack sends it as bin and it lands as the Uint8List the client
declares, instead of raising a String/Uint8List cast error.

Mounting services no longer swallows its own errors: a failed mount used to
surface later as an unexplained invoke timeout.

Also removes the duplicated WireCodec and WebSocketConnection now provided by
ruflet_server, and adds a Gemfile/Rakefile so the package resolves against the
local sibling gems and runs its own suite.

282 runs, 1863 assertions, 0 failures.
…1504

# Conflicts:
#	packages/ruflet/test/update_command_test.rb
Attributes like class/icon/variant/on-load and the on-*-target family are
markup the DSL consumes itself. Passing them through as invoke arguments
produced calls the client could not match to a control's method signature,
which is why the ERB audio buttons did nothing.
erb_to_native dispatches every screen in-process, so a screen URL never
addresses anything reachable: the client opens one route, the WebSocket,
and the rest is a function call. Requiring an absolute start_url made that
look like a second HTTP endpoint, and pinned it to whatever host was
hardcoded in backend_url — a phone on the LAN then rendered against
127.0.0.1, and a mismatched host answered 403 from host authorization.

A path start_url now takes its host from the connection the client
actually made, so the same route serves localhost and the LAN unchanged.
Absolute URLs keep working and still name the host verbatim.

Also stop putting the default port in Host; a browser omits it, and Rails
echoes the header back through url_for and redirects.
Screens are dispatched in-process, but the client loads media itself over
HTTP, so a src/url naming a Rails-hosted file was the one URL still going
out unresolved: a root-relative "/assets/logo.png" reached the client as
a path it could only look for among its own bundled assets. Resolve those
against the connection host. External URLs, data: URIs, protocol-relative
URLs and bare asset names are left alone.

Also reject a screen URL with a scheme but no host. "localhost:3000"
parses as scheme "localhost" with opaque "3000", which passed through
as absolute and dispatched to "/" — rendering the root route while
appearing to ignore start_url.
A native app has one public route, the WebSocket; the screens behind it
are reached only from inside a session. Declaring a Rails route per screen
therefore builds a routing table nothing outside the app may use. Resolve
screen paths to controller actions by convention instead, from a catch-all
the Railtie appends and constrains to Ruflet sessions, so an app's
routes.rb carries only its own routes and the WebSocket mount.

Extra path segments are tried as part of the action name before becoming
params, which is what lets one convention cover both an action nested
under another (counter/increment) and a parameterised screen (device/:id).

This keeps the request cycle. It does not yet address handlers running as
Ruby in the session rather than as a controller action per tap.
TemplateSource puts the ERB file in the slot a Ruflet Ruby app file
occupies: templates render through ActionView directly, a tap calls a
method on the developer's own controller in the session, and state lives
on that controller instance for the life of the connection. No routing, no
middleware, no Rack env — verified zero process_action.action_controller
notifications across a full navigation.

Opt-in via template_mode: the default still goes through RackFetcher, so
nothing existing changes behaviour.

Incomplete: re-render after an action does not reach the screen body, form
submission does not round-trip, and a controller calling redirect_to
raises because there is no request to redirect.
Screens reach for the app's own helpers as readily as the DSL's tags, and
render partials relative to their controller's view folder, so both have
to work without a controller behind them: mix in the controller's _helpers
module, and give each render a lookup context carrying its own prefix.

The view class is built once per session rather than per render. A
template compiles its partials into the class that rendered it, so a fresh
class each time leaves those methods behind on the previous one, which is
why every screen with a partial failed on its second render.
Re-rendering a screen parses its markup and rebuilds the whole control
tree, which measures at roughly 0.41ms against 0.24ms for the ERB render
that produced it — the transform, not the template, is what a tap costs.
When an action returns byte-identical markup none of that work can change
anything, and neither can the diff it feeds, so return early.
view.render re-resolved the template and fired the render instrumentation
on every tap, and a screen re-renders on every tap: 0.223ms against
0.055ms for calling the compiled template. Full fetch drops from 0.218ms
to 0.067ms.

Holding a compiled template would otherwise defeat template reloading, so
re-resolve when the file's mtime changes. Reloading did not in fact work
here before either — nothing in this path runs inside Rails' reloader, so
an edited screen was never invalidated — which the mtime check now fixes
by clearing the process-wide resolver cache with it.

That clearing must happen only once a file has really changed. Discarding
the view class on a first resolve strands the partials already compiled
into it, which took 28 of the 37 screens down.
A partial still goes through view.render — that is how a template asks for
one — and each announces itself to the log subscriber. A screen re-renders
on every tap, so a partial-backed screen wrote a line per tap for work
that is not a request and has nothing to report.

This is noise removal, not speed: measured against an unsilenced render it
is 0.272ms versus 0.260ms, so the subscriber was costing nothing worth
reclaiming. Silencing is per-thread, so a concurrent web request keeps its
own logging.
Dropping the request cycle took Rails' session with it, which pushed every
app onto instance variables and made a working controller something you
had to rewrite to adopt this. Hold a session per connection instead and
expose it as session, so session[:count] keeps its meaning: it outlives
the tap, like a Rails session, but is held by the connection rather than a
cookie.
An action like #counter_increment changes state and then re-renders
counter.html.erb, but the ivars that template reads are assigned by
#counter, which never ran. The screen redrew from whatever the previous
visit left behind, so every tap worked and the number never moved.

Run the screen's own action too when it differs from the one the tap
named, so the template always reads current state.
Ruflet::Rails now offers erb_to_native, native_shell and native, and
nothing else. Removed with the Flutter web frontend: Protocol::WebApp and
Ruflet::Rails.web, WebInstaller and the ruflet:web rake task, the
ruflet_frame helper that embedded that mount in an iframe, and the
generator's web client paths.

RackFetcher goes too. It dispatched screens through Rails routing and
middleware, which is the request-per-tap that erb_to_native no longer
does, so it was a second way to mean the same thing that reintroduced
what the design removed. TemplateSource owns Response now, and
NativeScreens keeps only resolve — its Rack dispatcher and the catch-all
route the Railtie appended existed solely for that HTTP path.

The view class is now shared process-wide rather than per session.
ActionView caches compiled templates process-wide and a template compiles
into the class that first rendered it, so a class per session handed the
second connection a template that believed it was already compiled: every
shared screen raised NoMethodError. Two clients at once was enough.
ControllerHelpers answered a question nothing asks any more:
ruflet_native_request? read the X-Ruflet-Native header, and RackFetcher
was the only thing that ever set it, while render_native existed to
collapse the Post/Redirect/Get double-fetch that only a request has.

The HTML DSL's CSRF plumbing goes with it — the csrf-token meta scan, the
token carried on each Screen, and the X-CSRF-Token header. There is no
request to forge and nothing reads a header. The CSRF JS in native_shell
stays: a WebView really does post a form.

Also ruflet_css_dimension, which only ever sized the iframe that
ruflet_frame rendered.
Parameters nothing reads: the screen threaded through HtmlApp#request and
the headers passed to a fetcher, both left over from CSRF; the source
screen given to #navigate, which never consulted it; styles on
build_button, form on build_chip and finalize; root on desktop_enabled?;
ruflet_url on build_args_for_platform and target on install_next_steps,
with the generator's install_target that existed only to supply it.

The comments were worse than unused, because they were wrong: NativeScreens
still promised dispatch through Rails' middleware with session, flash and
CSRF intact, HtmlApp described actions round-tripping to Rails and
redirects being followed, and ViewHelpers opened by documenting the
ruflet_frame that no longer exists.
lookup_context was left memoising a context nothing asks for once render
started building one per prefix, and ScreenContext#ruflet_native_request?
answered a question no caller has: with no request behind a screen it can
only ever say true.
layout: and view_paths: were options with no caller: the layout path also
kept a second, slower render alive purely to honour it, and view_paths
existed only as a seam a test never used.

assign_params knew a screen's segment was called "feature", which is a
name from the demo's device_feature screen, not something a gem can know.
Trailing segments are just id and param1..n now.
Nothing has made a request since TemplateSource replaced RackFetcher, but
the shape survived it: an on-click carried a verb, a form carried a
method, and a screen came back wrapped in a status code.

The status was not merely decorative. render_result treated anything >=400
as a Rails error page, so it threw away the markup TemplateSource writes
for a missing or failed screen and substituted "HTTP 404 / Server error".
A screen that says which path had no action now survives to the client.

A leading verb on an on-click is stripped rather than obeyed, so existing
"post:/path" markup keeps working.
The audio recorder's Record and Stop buttons carry indicator-target,
record-target and stop-target: names of controls to update when recording
starts and stops. They were not in the meta-key list, so they went to the
device as arguments to audio_recorder_start, which has no such parameters
— the client could not match the call and the tap did nothing.

Listing three more names would have left the next invented one broken, so
match the shape: an attribute ending in -target names a control, never an
argument.
The recorder's whole cycle depends on it: start, stop, autoplay, start
again all work once no control name reaches the device as an argument.
@AdamMusa AdamMusa changed the title Claude/ruflet rails erb native d11504 ruflet rails erb native d11504 Aug 12, 2026
@AdamMusa
AdamMusa merged commit 875c671 into main Aug 12, 2026
10 checks passed
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.

1 participant