ruflet rails erb native d11504 - #25
Merged
Merged
Conversation
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.
…uflet-rails-erb-native-d11504
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.