view_bind 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45fd0d48621262e28bbfb10957903033b51390f54a54b479ba2ae97dda1c3e3c
4
+ data.tar.gz: 3fe098f2d09c228f374be992470d0486603aec240bcb3d20f9ca947ddbe9772c
5
+ SHA512:
6
+ metadata.gz: c95cba0f1a80d9f61c08f5176be215d2a934865e370e21431bbb7da0255bd98e7c4fd50965f435fd0f8c20fbae872ab1b0019a2fe4890f559aa20be6f19d44a2
7
+ data.tar.gz: 66f447f82d5d74d5e5529b3c20646c7bd716a18691d7c149731d3b2d6595b4e2cc70492cebad47fd9b753a21c9f92f0ae4ed2f6d0006f69d20d396a64a67caf5
data/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Preserve empty and whitespace-only output in `bind_capture` and `bind_render_memo`.
6
+ - Render non-ERB handlers through `Template#render` so raw and static HTML output is
7
+ preserved by every helper, including collections.
8
+ - Reuse the caller's buffer for strict-locals ERB, and reuse the implicit-locals list
9
+ across collection items to avoid per-partial allocations.
10
+ - Skip memoization for zero-valued Floats so `0.0` and `-0.0` keep their distinct output.
11
+
12
+ ## [0.1.0] - 2026-09-10
13
+
14
+ - `bind_capture` rejects unsupported blocks instead of silently dropping their content.
15
+ - Dummy-app smoke checks clear inherited `LOG_LEVEL` for the unset-variable case.
16
+ - Responsive publication layout for the dummy app, with equivalent output across all routes.
17
+ - Reproducible request benchmarks with randomized rounds, median and range reporting,
18
+ response validation, accurate query counts, configurable page size and raw JSON output.
19
+ - Concurrent first-render and per-view memo isolation regression coverage.
20
+ - Include the changelog in the built gem.
21
+ - `bind_render`, `bind_render_memo` and `bind_render_each` raise on render's option names
22
+ (`locals:`, `object:`, `collection:`, `partial:`, `layout:`, …) instead of silently
23
+ passing them to the partial as locals.
24
+ - `bind_render_each` accepts `as:` as a String and rejects a name that is not a valid Ruby
25
+ identifier, the way `render collection:` does.
26
+ - The dependency tracker ignores interpolated paths rather than reporting a dependency
27
+ that resolves to nothing.
28
+ - Releases require MFA (`rubygems_mfa_required`), and CI covers Rails 7.2.
29
+ - `bind_render` and `bind_render_each`: render a partial through its own compiled method.
30
+ - Dependency tracker so `cache` digests still bust when a bound partial changes.
31
+ - Lookup cache keyed by the whole resolver context — `details_key`, view paths and prefixes —
32
+ so locales, variants, themed or engine view paths and relative partial names all resolve
33
+ correctly, and `ViewBind.clear_cache` invalidates views that already warmed.
34
+ - `bind_render_memo` keys on HTML safety as well as value, so an `html_safe` local and an
35
+ equal escaped one never share an entry.
36
+ - Dependency tracking covers every helper form, with or without parentheses.
37
+ - Relative bound paths resolve against the template's directory for digests, so a `cache`
38
+ block above `bind_render "card"` busts when `card` changes.
39
+ - Prefix strings are snapshotted, not just the array, so editing one in place is noticed.
40
+ - The profiler measures strict-locals collections, and counts a delegated memo call at its
41
+ own nesting level so its time reaches the header total.
42
+ - `rake coverage`: the suite under SimpleCov, gated at 100% line and branch coverage.
43
+ - CI matrix over Rails 7.1, 7.2 and 8.0, plus the newest release through the default
44
+ `Gemfile`.
45
+ - Templates re-resolved in development, cached in production.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Igor Kasyanchuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,365 @@
1
+ # view_bind
2
+
3
+ Render a Rails partial by calling the method ActionView already compiled for it, instead of
4
+ walking the whole `render` path on every call.
5
+
6
+ ```erb
7
+ <%# before %>
8
+ <%= render partial: "posts/card", collection: @posts, as: :post %>
9
+ <%= render "shared/button", label: "Read", style: "primary" %>
10
+
11
+ <%# after %>
12
+ <%= bind_render_each "posts/card", @posts, as: :post %>
13
+ <%= bind_render "shared/button", label: "Read", style: "primary" %>
14
+ ```
15
+
16
+ Partials stay ordinary partials. Nothing is merged into the parent template, no source is
17
+ rewritten, and every file keeps its own identity — so backtraces, `local_assigns`, strict
18
+ locals, development reloading and fragment cache digests all keep working.
19
+
20
+ ## Why it is faster
21
+
22
+ A normal partial render performs template lookup, creates rendering objects and buffers,
23
+ and emits ActiveSupport notifications. For a page with many small partials, that work adds up.
24
+
25
+ `bind_render` caches the resolved template by resolver context and locals shape, then calls
26
+ the method Rails compiled for it, writing into the current output buffer.
27
+ `bind_render_each` also resolves once and reuses view bookkeeping across the collection.
28
+ The direct call is used for the stock ERB handler. Strict-locals templates use
29
+ `Template#render` for Rails' argument validation while reusing the caller's buffer.
30
+ Other handlers also use `Template#render`,
31
+ preserving output from handlers that return a string or a new buffer.
32
+
33
+ ### bind_render_memo
34
+
35
+ Memoization reuses a partial's markup within one view context:
36
+
37
+ ```erb
38
+ <%= bind_render_memo "shared/tag", tag: "ruby" %>
39
+ ```
40
+
41
+ Only String, Symbol, Numeric, true, false and nil values are memoized. Zero-valued Floats
42
+ fall through to rendering because `0.0` and `-0.0` compare as the same hash key but render
43
+ differently. Other objects also fall through. Keys include resolver context, locals names,
44
+ values and HTML safety; mutable strings are snapshotted so later mutation does not corrupt
45
+ stored keys.
46
+
47
+ Use it only when repeating those inputs should produce the same markup. Instance variables,
48
+ current-user state, time and side effects are not part of the key. Request state can change
49
+ within a render: a shared request alone does not guarantee correctness.
50
+ A hit skips all partial side effects, including `content_for`, `provide` and counters.
51
+ It behaves the same with template caching enabled or disabled.
52
+
53
+ Memoization can reduce allocations without improving latency. Measure the subtree you want
54
+ to memoize; the lookup itself has a cost.
55
+
56
+ ## Install
57
+
58
+ ```ruby
59
+ gem "view_bind"
60
+ ```
61
+
62
+ Nothing to configure. The railtie adds the helpers to every view, partial, layout and mailer
63
+ view, and registers a dependency tracker so `cache` digests still notice bound partials.
64
+
65
+ ## Usage
66
+
67
+ Works the same in a view, in a partial, and in a layout:
68
+
69
+ ```erb
70
+ <%# app/views/layouts/application.html.erb %>
71
+ <body>
72
+ <%= bind_render "shared/header", user: current_user %>
73
+ <main><%= yield %></main>
74
+ <%= bind_render "shared/footer" %>
75
+ </body>
76
+ ```
77
+
78
+ ```erb
79
+ <%# app/views/posts/index.html.erb %>
80
+ <section class="cards">
81
+ <%= bind_render_each "posts/card", @posts, as: :post %>
82
+ </section>
83
+ ```
84
+
85
+ `bind_render_each` provides `<as>_counter` and `<as>_iteration` exactly like
86
+ `render collection:`, so existing collection partials keep working:
87
+
88
+ ```erb
89
+ <%# app/views/posts/_card.html.erb %>
90
+ <article class="card <%= "is-first" if post_iteration.first? %>">
91
+ <h3><%= post_counter + 1 %>. <%= post.title %></h3>
92
+ </article>
93
+ ```
94
+
95
+ Both helpers write into the buffer and return `nil`, so `<%= %>` appends nothing extra. When
96
+ you need the markup **as a value** — `content_for`, a helper argument — use `bind_capture`,
97
+ which returns a safe string, preserving empty and whitespace-only output:
98
+
99
+ ```erb
100
+ <% content_for :sidebar, bind_capture("shared/widget") %>
101
+ ```
102
+
103
+ Passing `bind_render` itself as a value would put the HTML in the page and store an empty
104
+ string, so the block form of `render` is not supported either: passing a block raises
105
+ `ArgumentError` rather than dropping it silently.
106
+
107
+ ## Benchmark
108
+
109
+ The dummy app renders a responsive publication page with a featured article, a card grid,
110
+ navigation, tags, author information, buttons, community statistics, ranked posts and comments.
111
+ It uses 2,000 posts, 6,000 comments and 10 authors, with **10 SQL queries per request**.
112
+
113
+ All five routes produce equivalent HTML after normalizing the footer timestamp. The baseline
114
+ already uses Rails' collection renderer. The default is 200 cards, which deliberately exercises
115
+ many nested partials; use `PER=20` for a smaller page.
116
+
117
+ ```sh
118
+ bundle exec rake bench
119
+ PER=20 bundle exec rake bench
120
+ OUTPUT=tmp/benchmark.json bundle exec rake bench
121
+
122
+ # Use a dedicated database: the dummy app creates/reseeds its tables.
123
+ createdb view_bind_bench
124
+ DB=postgres PGUSER=your_user PGDATABASE=view_bind_bench bundle exec rake bench
125
+
126
+ # Synthetic notification subscribers, not a named production APM agent:
127
+ APM=1 bundle exec rake bench
128
+ ```
129
+
130
+ The runner defaults to production mode, 15 warmup requests per case and nine rounds of twenty
131
+ requests. Case order is randomized using a reproducible seed. Override `WARMUP`, `R`, `N`,
132
+ `SEED` or `PER` as needed; `PER` accepts 1–500. `OUTPUT` saves metadata and all raw rounds.
133
+ Use `RAILS_ENV=development bundle exec ruby benchmarks/run.rb` for an explicit development run;
134
+ `rake bench` always selects production.
135
+
136
+ Reported times are **median batch averages**; min–max describes batch variation, not request
137
+ latency percentiles. Each request must return HTTP 200, and output equivalence is checked
138
+ before timing and after every batch. Allocations are averaged per request, not universal constants.
139
+
140
+ The `observed` column counts template instances reported through Rails notifications, including
141
+ collection payload counts. Bound templates still execute even though they emit fewer events.
142
+ The default timing excludes the benchmark's inspection subscribers.
143
+
144
+ See [the current measured results](benchmarks/results/README.md) and accompanying raw JSON.
145
+ These are serial in-process measurements, not browser load times or concurrent throughput.
146
+ Database latency, page size, GC and instrumentation affect the result; benchmark your own page.
147
+ No leaf-level microsecond or profiler-overhead claims are inferred from this request benchmark.
148
+
149
+ ### Concurrent throughput, over HTTP
150
+
151
+ The numbers above are serial and in-process. This is the same page behind Puma, measured with
152
+ ApacheBench, so the socket, the router and the middleware stack are all included:
153
+
154
+ ```sh
155
+ RAILS_ENV=production LOG_LEVEL=info bin/rails s -b 127.0.0.1 -p 3000
156
+ ```
157
+
158
+ ```sh
159
+ ab -n 200 -c 2 'http://127.0.0.1:3000/?per=200'
160
+ ab -n 200 -c 2 'http://127.0.0.1:3000/bind_both?per=200'
161
+ ```
162
+
163
+ | | `/` (standard Rails) | `/bind_both` (view\_bind) |
164
+ | --- | ---: | ---: |
165
+ | Requests/sec | 79.12 | **181.20** |
166
+ | Mean request time | 25.28 ms | **11.04 ms** |
167
+ | Median latency | 25 ms | **11 ms** |
168
+ | p95 latency | 30 ms | **12 ms** |
169
+ | Total for 200 requests | 2.528 s | **1.104 s** |
170
+ | Failed requests | 0 | 0 |
171
+
172
+ **2.29x the throughput (+129%) and 56% lower mean request time**, both routes returning the
173
+ same 228 KB of HTML. Medians of three runs of 200 requests at concurrency 2 after warming, on
174
+ one Puma worker with five threads over loopback, Ruby 3.4.5 +YJIT and SQLite.
175
+
176
+ `/bind_memo` measures the same as `/bind_both` here (178.66 req/s, 11.20 ms, inside the
177
+ run-to-run spread). Over HTTP the remaining time is dominated by the 10 queries and the request
178
+ cycle, so the memo's extra saving only shows up in the in-process table.
179
+
180
+ `ab` runs on the same machine as the server and competes with it for cores, and a laptop under
181
+ load will not reproduce these exact figures. Run it against your own page.
182
+
183
+ ## What it does not change
184
+
185
+ Each of these is a test in the suite, because each is a way this kind of optimisation usually
186
+ goes wrong:
187
+
188
+ | behaviour | test |
189
+ | --- | --- |
190
+ | Same HTML as `render` | `test_matches_what_render_produces` |
191
+ | Locals, including strict locals | `test_passes_locals`, `test_supports_strict_locals` |
192
+ | `_counter` / `_iteration` in collections | `test_collection_provides_counter_and_iteration` |
193
+ | Per-locale / per-variant partials | `test_respects_locale` |
194
+ | Per-view-path and per-prefix partials | `test_respects_view_paths`, `test_respects_prefixes_for_a_relative_path` |
195
+ | Backtraces naming the real file and line | `test_backtrace_points_at_the_partial` |
196
+ | Fragment cache digests busting on edits | `test_dependency_tracking_busts_fragment_digests` |
197
+ | Works in a layout, and nested | `test_works_in_a_layout_and_nested_partials` |
198
+ | Instance variables in nested partials | `test_instance_variables_reach_nested_partials` |
199
+ | Missing partial still raises `MissingTemplate` | `test_missing_partial_raises_missing_template` |
200
+ | `bind_capture` returns markup, `content_for` works | `test_bind_capture_works_with_content_for` |
201
+ | A block raises instead of being dropped | `test_block_form_raises_instead_of_being_ignored` |
202
+ | The output-buffer limitation stays as documented | `test_a_partial_that_hijacks_the_output_buffer_renders_nothing` |
203
+ | Memo keys on the locals shape, not just values | `test_memo_does_not_collide_on_the_value_alone` |
204
+ | Memo behaves the same with caching on and off | `test_memo_behaves_the_same_with_and_without_template_caching` |
205
+ | Memo respects a variant or locale change | `test_memo_respects_a_variant_change` |
206
+ | Memo never shares an entry between safe and escaped strings | `test_memo_does_not_share_an_entry_between_safe_and_unsafe_strings` |
207
+ | Every helper form is tracked for digests | `test_tracker_finds_every_public_helper_form` |
208
+ | Relative bound paths bust digests too | `test_dependency_tracking_busts_digests_for_a_relative_path` |
209
+ | `clear_cache` invalidates a warmed view | `test_clear_cache_invalidates_a_warmed_view` |
210
+ | A prefix edited in place is noticed | `test_notices_a_prefix_mutated_in_place` |
211
+ | A nil prefix list still renders | `test_tolerates_nil_prefixes` |
212
+ | A memo key cannot be forged by its value | `test_memo_cannot_be_forged_by_a_value_shaped_like_a_safety_marker` |
213
+ | A SafeBuffer memo key gets its own copy | `test_memo_snapshots_a_safe_buffer_used_as_the_whole_key` |
214
+ | A delegated memo call is timed at its own depth | `test_profiler_counts_a_delegated_memo_call_once` |
215
+ | Memoised side effects run once, as documented | `test_memo_runs_side_effects_once` |
216
+ | A strict-locals partial in a collection | `test_collection_supports_strict_locals` |
217
+ | The railtie's per-request profile line | `test_profiling_logs_one_summary_per_request` |
218
+ | The gem loads outside Rails | `test_loads_without_rails` |
219
+
220
+ `rake coverage` runs the same suite under SimpleCov and fails below 100% line **and**
221
+ branch coverage of `lib/`. The one branch a single process cannot reach — the railtie
222
+ `require`, which is skipped only where `Rails::Railtie` is undefined — is covered by the
223
+ child process in `test_loads_without_rails`, whose result is merged into the suite's.
224
+
225
+ CI runs the suite against Rails 7.1, 7.2 and 8.0 (`gemfiles/`), and against the newest
226
+ release through the default `Gemfile` (8.1 today, unpinned), because the fast path calls
227
+ ActionView internals that move between versions. The dependency remains `actionview >= 7.1`.
228
+ `ViewBind.fast_path_available?` detects missing methods and selects `Template#render`, but
229
+ method existence cannot guarantee compatible signatures or behavior in future Rails releases.
230
+ Validate framework upgrades against your application's rendering tests before deploying them.
231
+
232
+ In development, templates are re-resolved on every call (guarded on
233
+ `ActionView::Resolver.caching?`), so editing a partial works without a restart — and Rails'
234
+ debug error page is unchanged. A `NoMethodError` inside a bound partial reports:
235
+
236
+ ```
237
+ Showing .../views/shared/_button.html.erb where line #3 raised:
238
+ undefined method 'nonexistent_method' for an instance of String
239
+ ```
240
+
241
+ with the partial's own source extracted around the failing line, exactly as `render` does.
242
+ That is not a trick this gem plays: the partial is a normal compiled template, so
243
+ `backtrace_locations`, `SourceMapLocation` and ErrorHighlight all resolve it the usual way.
244
+
245
+ ## Seeing where the time goes
246
+
247
+ Bound partials produce no `render_partial.action_view` events — that is part of what makes them
248
+ cheap — so the per-partial log lines go with them. In their place, one summary per request:
249
+
250
+ ```ruby
251
+ # config/environments/development.rb
252
+ ViewBind.profile = true
253
+ ```
254
+
255
+ ```
256
+ ViewBind: 302 calls, 24.10ms in bound partials
257
+ posts/card_memo x20 17.76ms
258
+ shared/sidebar_bound x1 3.66ms
259
+ posts/author_bound x20 3.33ms
260
+ posts/ownership_bound x20 1.80ms (19 memo)
261
+ shared/button x61 1.70ms
262
+ ```
263
+
264
+ Sorted by time, top ten, one line per partial rather than one per render. Per-partial times nest
265
+ exactly as Rails' own do — `posts/card_memo` includes everything its children spent — so the
266
+ header totals only the outermost calls rather than summing rows that overlap. The measurement
267
+ starts before the binding lookup, so it covers what a call actually costs, not just the partial. `(19 memo)` counts the
268
+ calls `bind_render_memo` served without rendering — the hit rate, which is the number worth
269
+ checking before deciding whether memoisation earns its place.
270
+
271
+ The store is cleared when an action starts as well as when it ends, so renders from a mailer or
272
+ a job on the same thread cannot be attributed to the next request.
273
+
274
+ Off by default. While off the cost is a single boolean test per call; switched on it adds two
275
+ clock reads, about **0.26 µs per call** — fine for development, not something to leave on in
276
+ production.
277
+
278
+ ## Limitations
279
+
280
+ - No `:layout`, `:spacer_template`, `:cached` or `:object` options. Use `render` where you need
281
+ them — the two can be mixed freely in the same template.
282
+ - The dependency tracker finds `bind_render "some/partial"` by literal string, in every helper
283
+ form and with or without parentheses, and resolves a relative name against the template's own
284
+ directory the way the renderer does. A path built at runtime is invisible to it, so a `cache`
285
+ block above a dynamically bound partial can go stale. Same caveat as Rails' own tracker with
286
+ dynamic `render`.
287
+ - Dependency tracking is registered for ERB only. For another engine, add
288
+ `ViewBind::Tracker.register_for(:haml)` in an initializer. Registration extends whatever
289
+ tracker is already installed for that handler rather than replacing it.
290
+ - No `render` instrumentation is emitted for bound partials, by design. Your APM will show
291
+ fewer view events, and Rails' per-partial `Rendered …` log lines disappear for them — 65
292
+ lines become 2 on the benchmark page. The `Completed … (Views: 16.9ms)` total is unaffected.
293
+ See **Seeing where the time goes** below for the replacement.
294
+ - An ERB partial on the direct path that reassigns `@output_buffer` without restoring it
295
+ loses its output. That path writes into the buffer it is given, whereas `render` builds
296
+ its own buffer and takes whatever the partial returns, so it survives that. `capture`
297
+ and `with_output_buffer` restore the buffer and are unaffected; only code that assigns
298
+ the ivar and walks away is.
299
+ Inside `bind_render_each` such an item takes the rest of the collection with it.
300
+ - The resolved-template cache is not evicted. It is keyed per call site, per resolver context
301
+ (lookup details, view paths and prefixes), so it is bounded in practice — but passing a varying
302
+ set of locals keys to the same partial grows it.
303
+
304
+ ## When not to use it
305
+
306
+ If a page makes a handful of render calls, this changes nothing measurable. Reach for it when a
307
+ partial is called once per row and there are many rows. Before that, check whether you are
308
+ loading ActiveRecord objects you only read from (`pluck` is usually a bigger win) or rendering
309
+ more rows than anyone will look at.
310
+
311
+ ## The dummy app
312
+
313
+ `benchmarks/app.rb` is a single-file Rails application with a persistent SQLite database by
314
+ default, or PostgreSQL when `DB=postgres`. The templates share presentation copy, inline CSS
315
+ and the same database workload across all rendering modes.
316
+
317
+ ```sh
318
+ bin/rails s
319
+ # Open http://localhost:3000/?per=6 for a short visual preview.
320
+
321
+ # Production caching, with request and completion logs:
322
+ RAILS_ENV=production LOG_LEVEL=info bin/rails s
323
+
324
+ # The original launcher is also available on port 9292:
325
+ bundle exec rake dummy
326
+ ```
327
+
328
+ Development writes normal Rails request, rendering and SQL logs to the terminal and enables
329
+ ViewBind profiling summaries. Production disables profiling and discards logs by default; set
330
+ `LOG_LEVEL=info` to write request logs to the terminal. Stop an existing server with Ctrl-C
331
+ before restarting on the same port.
332
+
333
+ Benchmarking is separate: `benchmarks/run.rb` turns profiling off in every environment, so it
334
+ never times bound renders through `Profiler.measure` while leaving the baseline's `render`
335
+ untouched, and runs stay quiet unless `LOG_LEVEL` is set. `bundle exec rake smoke` checks that
336
+ logging configuration without running a benchmark.
337
+
338
+ | route | layout | view |
339
+ | --- | --- | --- |
340
+ | `/` | `render` | `render` |
341
+ | `/bind_view` | `render` | `bind_render` |
342
+ | `/bind_layout` | `bind_render` | `render` |
343
+ | `/bind_both` | `bind_render` | `bind_render` |
344
+ | `/bind_memo` | `bind_render` | bound cards with a memoized ownership subtree |
345
+
346
+ This is a rendering fixture, not a complete publication app: post/tag detail routes and
347
+ account, sharing and saving actions are placeholders.
348
+
349
+ ## Development
350
+
351
+ ```sh
352
+ bin/setup
353
+ bundle exec rake test
354
+ bundle exec rake coverage
355
+ bundle exec rake bench
356
+ bundle exec rake dummy
357
+ BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle install
358
+ BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test
359
+ BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle install
360
+ BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle exec rake test
361
+ ```
362
+
363
+ ## License
364
+
365
+ MIT.
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewBind
4
+ # Helpers available in every view, partial and layout.
5
+ module Helper
6
+ # Cap on distinct memo entries per partial, per locals shape, per HTML-safety mask, per
7
+ # resolver context. The memo dies with the request, so this only bounds a single page
8
+ # built from an unbounded set of locals values.
9
+ #
10
+ # The mask is part of what is counted, so a partial whose locals arrive sometimes
11
+ # html_safe and sometimes not can hold this many entries per mask it actually sees.
12
+ # Counting across masks instead would put a sum over the mask table on the hot path of
13
+ # every call, which costs more than the bound is worth: the masks a call site produces
14
+ # are bounded by 2**locals.size and the whole memo dies with the request.
15
+ MEMO_LIMIT_PER_SHAPE = 512
16
+ # Names `render` treats as options rather than locals. Every keyword these helpers take is
17
+ # a local, so a `render "card", object: post` mechanically ported to bind_render would
18
+ # silently become a local named `object` and the partial would render with the wrong data.
19
+ # None of these options are implemented -- use `render` where you need them -- so passing
20
+ # one raises instead. A Hash rather than an Array: the check runs per call.
21
+ RENDER_OPTIONS = %i[partial collection object locals layout spacer_template cached as]
22
+ .to_h { |name| [name, true] }.freeze
23
+ # Rails' own rule for `as`, from ActionView's AbstractRenderer.
24
+ AS_PATTERN = /\A[a-z_][a-zA-Z_0-9]*\z/
25
+ # Render a partial by calling its own compiled method, straight into the current buffer.
26
+ #
27
+ # <%= bind_render "shared/header" %>
28
+ # <%= bind_render "posts/card", post: post %>
29
+ #
30
+ # Writes into the buffer and returns nil, so `<%= %>` appends nothing extra. When you
31
+ # need the markup as a value -- `content_for(:side, ...)`, a helper argument -- use
32
+ # #bind_capture, which returns a string.
33
+ def bind_render(path, **locals, &block)
34
+ raise ArgumentError, "bind_render does not support a block; use render for the block form" if block
35
+ reject_render_options!(:bind_render, locals)
36
+
37
+ unless ViewBind.profile?
38
+ render_bound(ViewBind.bound_for_locals(self, path, locals), locals)
39
+ return nil
40
+ end
41
+
42
+ # The lookup is part of what a call costs, so it is inside the measurement.
43
+ ViewBind::Profiler.measure(path) do
44
+ render_bound(ViewBind.bound_for_locals(self, path, locals), locals)
45
+ end
46
+ nil
47
+ end
48
+
49
+ # Render a partial and return its HTML instead of writing it to the buffer.
50
+ #
51
+ # <% content_for :sidebar, bind_capture("shared/widget") %>
52
+ def bind_capture(path, **locals, &block)
53
+ raise ArgumentError, "bind_capture does not support a block; use render for the block form" if block
54
+ # Returning the buffer makes Rails preserve even empty or whitespace-only output.
55
+ capture { bind_render(path, **locals); output_buffer }
56
+ end
57
+
58
+ # Render a partial once per distinct set of locals *values*, reusing the markup for
59
+ # every repeat within this request.
60
+ #
61
+ # <%= bind_render_memo "shared/tag", tag: tag %>
62
+ #
63
+ # For a partial that is a pure function of its locals -- no ivars, no `Time.now`, no
64
+ # counters, nothing but the values passed in -- this collapses hundreds of renders into
65
+ # a handful. A page listing 600 tags drawn from eight distinct strings renders eight.
66
+ #
67
+ # A hit appends the stored markup without running the partial, so anything the partial
68
+ # does besides producing markup happens once: `content_for`, `provide`, incrementing an
69
+ # ivar, registering an asset. Memoise markup, not side effects.
70
+ #
71
+ # Only values it can safely compare are memoised (String, Symbol, Numeric, true, false,
72
+ # nil), except zero-valued Floats whose signs compare equal. Other values fall through
73
+ # to a normal render, so passing a record cannot serve you a stale card. The memo lives
74
+ # on the view, but request state can change during a render: helper state and side
75
+ # effects must still be excluded from a memoised partial.
76
+ def bind_render_memo(path, **locals, &block)
77
+ raise ArgumentError, "bind_render_memo does not support a block" if block
78
+ reject_render_options!(:bind_render_memo, locals)
79
+ # The key walk is part of what a memo call costs -- that is the number worth comparing
80
+ # against the render it replaces -- so it happens inside the measurement.
81
+ return (memo_render(path, locals); nil) unless ViewBind.profile?
82
+
83
+ ViewBind::Profiler.measure_memo(path) { memo_render(path, locals) }
84
+ nil
85
+ end
86
+
87
+ # Collection form. Resolves once, then one render per item reusing a single locals hash,
88
+ # the same way ActionView's own CollectionRenderer does.
89
+ #
90
+ # <%= bind_render_each "posts/card", @posts, as: :post %>
91
+ #
92
+ # Provides `<as>_counter` and `<as>_iteration` exactly like `render collection:`.
93
+ def bind_render_each(path, collection, as:, **shared, &block)
94
+ raise ArgumentError, "bind_render_each does not support a block" if block
95
+ reject_render_options!(:bind_render_each, shared)
96
+ as = normalize_as(as)
97
+
98
+ # PartialIteration ships with the collection renderer, which an app with eager_load
99
+ # disabled has not necessarily loaded yet. Required here rather than at gem load time,
100
+ # so that requiring view_bind before Rails cannot blow up.
101
+ require "action_view/renderer/collection_renderer" unless defined?(ActionView::PartialIteration)
102
+
103
+ collection = collection.to_a
104
+ locals = shared.dup
105
+ counter = :"#{as}_counter"
106
+ iteration = :"#{as}_iteration"
107
+ bound = ViewBind.bound_for(self, path, locals.keys + [as, counter, iteration])
108
+ buffer = output_buffer
109
+
110
+ partial_iteration = ActionView::PartialIteration.new(collection.size)
111
+ locals[iteration] = partial_iteration
112
+
113
+ if bound.slow
114
+ render_buffer = buffer if bound.writes_to_buffer
115
+ implicit_locals = [counter, iteration]
116
+ measuring_collection(path, collection.size) do
117
+ collection.each do |item|
118
+ locals[as] = item
119
+ locals[counter] = partial_iteration.index
120
+ rendered = bound.template.render(self, locals, render_buffer, implicit_locals: implicit_locals)
121
+ buffer << rendered unless render_buffer
122
+ partial_iteration.iterate!
123
+ end
124
+ end
125
+ return nil
126
+ end
127
+
128
+ # Every item renders the same template, so the view bookkeeping is saved and restored
129
+ # once for the whole collection instead of once per item.
130
+ previous_buffer = @output_buffer
131
+ previous_path = @virtual_path
132
+ previous_template = @current_template
133
+ @current_template = bound.template
134
+ @output_buffer = buffer
135
+ render_method = bound.unbound_method
136
+
137
+ begin
138
+ measuring_collection(path, collection.size) do
139
+ collection.each do |item|
140
+ locals[as] = item
141
+ locals[counter] = partial_iteration.index
142
+ render_method.bind_call(self, locals, buffer)
143
+ partial_iteration.iterate!
144
+ end
145
+ end
146
+ rescue StandardError => e
147
+ bound.template.send(:handle_render_error, self, e)
148
+ ensure
149
+ @output_buffer = previous_buffer
150
+ @virtual_path = previous_path
151
+ @current_template = previous_template
152
+ end
153
+ nil
154
+ end
155
+
156
+ private
157
+
158
+ # `as` may be given as a String, the way `render collection:` accepts it, and has to name a
159
+ # local the compiled template can actually declare.
160
+ def normalize_as(as)
161
+ name = as.to_sym
162
+ unless AS_PATTERN.match?(name.name)
163
+ raise ArgumentError, "The value (#{as}) of the option `as` is not a valid Ruby " \
164
+ "identifier; make sure it starts with lowercase letter, and is " \
165
+ "followed by any combination of letters, numbers and underscores."
166
+ end
167
+ if RENDER_OPTIONS[name]
168
+ raise ArgumentError, "`as: #{name.inspect}` collides with a render option name; " \
169
+ "pick another name for the item local."
170
+ end
171
+ name
172
+ end
173
+
174
+ # Raises when a caller passes one of render's option names as a local. Iterating the locals
175
+ # rather than the option list keeps this at one hash lookup for the usual one-local call.
176
+ def reject_render_options!(helper, locals)
177
+ locals.each_key do |key|
178
+ next unless RENDER_OPTIONS[key]
179
+
180
+ raise ArgumentError, "#{helper} takes locals, not render's options, and would have " \
181
+ "passed #{key.inspect} to the partial as a local. " \
182
+ "Use render if you need the #{key.inspect} option."
183
+ end
184
+ end
185
+
186
+ # Runs the block, timed as one row of `size` renders when profiling is on. Both collection
187
+ # loop bodies go through here: a strict-locals collection is still a supported render, so
188
+ # leaving it out made the summary silently disagree with the page.
189
+ def measuring_collection(path, size)
190
+ return yield unless ViewBind.profile?
191
+
192
+ ViewBind::Profiler.measure(path, count: size) { yield }
193
+ end
194
+
195
+ # Walks the memo, appends the markup and reports whether the call was a hit. A partial
196
+ # whose locals cannot be keyed on renders here too rather than being handed back to
197
+ # bind_render, so that the one measurement wrapping this call is the one that records it.
198
+ def memo_render(path, locals)
199
+ values = locals.values
200
+ # No block, no intermediate array: the type test is on the hot path of every call.
201
+ #
202
+ # `safety` records which values are html_safe. A SafeBuffer and an equal plain String
203
+ # are eql? and hash alike, but ERB escapes only the plain one, so they must not share a
204
+ # memo entry: whichever rendered first would decide the escaping for both, and markup
205
+ # meant to be escaped would be emitted raw. Keying on a separate mask rather than on a
206
+ # marker inside the value allocates nothing per call, and cannot be forged by a local
207
+ # that happens to start with the marker. A SafeBuffer whose html_safe? is false escapes
208
+ # exactly like a String, so it shares.
209
+ safety = 0
210
+ i = 0
211
+ while i < values.size
212
+ case (value = values[i])
213
+ when String
214
+ safety |= (1 << i) if value.html_safe?
215
+ i += 1
216
+ when Float
217
+ # Hash treats 0.0 and -0.0 as the same key, but they render different text.
218
+ if value.zero?
219
+ render_bound(ViewBind.bound_for_locals(self, path, locals), locals)
220
+ return false
221
+ end
222
+ i += 1
223
+ when Symbol, Numeric, true, false, nil then i += 1
224
+ else
225
+ # Going back through bind_render would time this render one level deeper than it
226
+ # really is, and its own measurement would then be discarded as nested: the row
227
+ # showed the elapsed time but the header total counted none of it.
228
+ render_bound(ViewBind.bound_for_locals(self, path, locals), locals)
229
+ return false
230
+ end
231
+ end
232
+
233
+ # Keyed by resolver context, then path, then the locals names, then which of them are
234
+ # html_safe, then their values.
235
+ #
236
+ # The context covers formats, locale, variants, view paths and prefixes: without it, a
237
+ # partial memoised before `lookup_context.variants = [:phone]`, inside
238
+ # `I18n.with_locale`, or under a different view path keeps serving the markup it was
239
+ # first rendered with. The names matter too -- `primary: "New"` and `secondary: "New"`
240
+ # are different renderings of one partial. A view that leaves a context and comes back
241
+ # to it gets a fresh context object and so an empty memo, which re-renders rather than
242
+ # serving anything stale.
243
+ memo = (@__view_bind_memo ||= {}.compare_by_identity)
244
+ by_path = (memo[ViewBind.context_for(self)] ||= {})
245
+ by_shape = (by_path[path] ||= {})
246
+ by_safety = (by_shape[locals.keys] ||= {})
247
+ by_value = (by_safety[safety] ||= {})
248
+ # One local is overwhelmingly the common case, and a bare value keys far cheaper than
249
+ # an array: no allocation, no array hashing.
250
+ key = values.size == 1 ? values[0] : values
251
+ hit = by_value.key?(key)
252
+
253
+ output_buffer << memo_fetch(by_value, key, hit, path, locals)
254
+ hit
255
+ end
256
+
257
+ # Returns the memoised markup, rendering and storing it on a miss. Rendering goes through
258
+ # the resolved binding rather than bind_render, so the profiler counts the call once.
259
+ def memo_fetch(by_value, key, hit, path, locals)
260
+ return by_value[key] if hit
261
+
262
+ bound = ViewBind.bound_for_locals(self, path, locals)
263
+ # Return the buffer so capture preserves blank output as a safe string, including
264
+ # spaces that separate surrounding text. Empty output is a cacheable result too.
265
+ rendered = capture { render_bound(bound, locals); output_buffer }
266
+ by_value[memo_key_snapshot(key)] = rendered if by_value.size < MEMO_LIMIT_PER_SHAPE
267
+ rendered
268
+ end
269
+
270
+ # Hash copies and freezes a key of its own accord only when that key's class is exactly
271
+ # String -- not a SafeBuffer, and not a String held inside an Array key. Anything the
272
+ # caller could still mutate therefore gets its own frozen copy here, or a later `<<` on
273
+ # the value that was passed would move the stored entry out of its own bucket. Only the
274
+ # miss path pays for this; a lookup goes on using the caller's value, which hashes the
275
+ # same. Copying a bare String too costs nothing, since it is the copy Hash would make.
276
+ def memo_key_snapshot(key)
277
+ case key
278
+ when Array then key.map { |value| memo_key_snapshot(value) }
279
+ when String then key.frozen? ? key : key.dup.freeze
280
+ else key
281
+ end
282
+ end
283
+
284
+ # Template#render owns strict-locals validation. ERB can still reuse our buffer; other
285
+ # handlers return their output, which we append with normal Rails escaping.
286
+ def render_bound(bound, locals)
287
+ if bound.slow
288
+ if bound.writes_to_buffer
289
+ bound.template.render(self, locals, output_buffer)
290
+ else
291
+ output_buffer << bound.template.render(self, locals)
292
+ end
293
+ else
294
+ bind_run(bound, locals, output_buffer)
295
+ end
296
+ end
297
+
298
+ # Mirrors ActionView::Base#_run. This lives in the helper, which is included in the view
299
+ # class, so it can save and restore the view's own ivars directly -- going through
300
+ # instance_variable_get/set costs more than the render it is wrapping.
301
+ def bind_run(bound, locals, buffer)
302
+ previous_buffer = @output_buffer
303
+ previous_path = @virtual_path
304
+ previous_template = @current_template
305
+
306
+ @current_template = bound.template
307
+ @output_buffer = buffer
308
+ bound.unbound_method.bind_call(self, locals, buffer)
309
+ nil
310
+ rescue StandardError => e
311
+ # Same wrapping Template#render does, so the error page still names the partial.
312
+ bound.template.send(:handle_render_error, self, e)
313
+ ensure
314
+ @output_buffer = previous_buffer
315
+ @virtual_path = previous_path
316
+ @current_template = previous_template
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewBind
4
+ # Bound partials emit no render_partial.action_view events -- that is part of what makes
5
+ # them cheap -- so Rails' per-partial "Rendered ..." log lines disappear for them. This is
6
+ # the replacement: one line per request instead of one per render.
7
+ #
8
+ # # config/environments/development.rb
9
+ # ViewBind.profile = true
10
+ #
11
+ # ViewBind: 2662 calls, 4.12ms
12
+ # posts/card_bound x200 1.83ms
13
+ # shared/tag x600 0.91ms
14
+ #
15
+ # Off by default, and when off the only cost is one boolean test per call.
16
+ module Profiler
17
+ KEY = :view_bind_profile
18
+ DEPTH = :view_bind_profile_depth
19
+
20
+ class << self
21
+ # Times a bound render. Nesting is tracked so the header can total only the outermost
22
+ # calls: a parent's duration already contains its children's, and adding every row
23
+ # would count the children twice.
24
+ def measure(path, count: 1, memo_hits: 0)
25
+ depth = Thread.current[DEPTH] || 0
26
+ Thread.current[DEPTH] = depth + 1
27
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ yield
29
+ ensure
30
+ Thread.current[DEPTH] = depth
31
+ record(path, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
32
+ count: count, memo_hits: memo_hits, top_level: depth.zero?)
33
+ end
34
+
35
+ # Same as #measure, for bind_render_memo: whether the call was a hit is only known once
36
+ # the block has run, so the block reports it by returning true (hit) or false (miss).
37
+ def measure_memo(path)
38
+ depth = Thread.current[DEPTH] || 0
39
+ Thread.current[DEPTH] = depth + 1
40
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ hit = yield
42
+ ensure
43
+ Thread.current[DEPTH] = depth
44
+ record(path, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
45
+ memo_hits: hit ? 1 : 0, top_level: depth.zero?)
46
+ end
47
+
48
+ # count: how many renders this call represents (a collection counts as its size).
49
+ # memo_hits: how many of them were served from bind_render_memo without rendering.
50
+ # top_level: whether this call was not nested inside another bound render.
51
+ def record(path, elapsed, count: 1, memo_hits: 0, top_level: true)
52
+ row = store[path]
53
+ row[0] += count
54
+ row[1] += elapsed
55
+ row[2] += memo_hits
56
+ row[3] += elapsed if top_level
57
+ end
58
+
59
+ def store
60
+ Thread.current[KEY] ||= Hash.new { |hash, key| hash[key] = [0, 0.0, 0, 0.0] }
61
+ end
62
+
63
+ # Called at the start and the end of an action. Renders outside a controller action --
64
+ # a mailer, a job, ActionCable -- would otherwise be counted against whichever request
65
+ # next runs on this thread.
66
+ def reset
67
+ Thread.current[KEY] = nil
68
+ Thread.current[DEPTH] = nil
69
+ end
70
+
71
+ # A pure read: returns nil when nothing was recorded, so the caller logs nothing and
72
+ # can ask twice without the second answer being empty.
73
+ def summary(limit: 10)
74
+ rows = Thread.current[KEY]
75
+ return nil if rows.nil? || rows.empty?
76
+
77
+ calls = rows.sum { |_, row| row[0] }
78
+ # Only outermost calls, so a parent and its children are not both counted.
79
+ total = rows.sum { |_, row| row[3] }
80
+ lines = ["ViewBind: #{calls} calls, #{format('%.2f', total * 1000)}ms in bound partials"]
81
+ rows.sort_by { |_, row| -row[1] }.first(limit).each do |path, (count, seconds, hits, _)|
82
+ suffix = hits.positive? ? " (#{hits} memo)" : ""
83
+ lines << format(" %-34s x%-6d %6.2fms%s", path, count, seconds * 1000, suffix)
84
+ end
85
+ omitted = rows.size - limit
86
+ lines << " … and #{omitted} more #{'partial'.pluralize(omitted)}" if omitted.positive?
87
+ lines.join("\n")
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ViewBind
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "view_bind.helper" do |app|
8
+ ActiveSupport.on_load(:action_view) do
9
+ include ViewBind::Helper
10
+
11
+ require "action_view/dependency_tracker"
12
+ ViewBind::Tracker.register_for(:erb)
13
+ end
14
+
15
+ # One summary per request, in place of the per-partial lines bound partials no longer
16
+ # produce. Subscribed once; does nothing while profiling is off.
17
+ ActiveSupport::Notifications.subscribe("start_processing.action_controller") do
18
+ ViewBind::Profiler.reset if ViewBind.profile?
19
+ end
20
+
21
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do
22
+ next unless ViewBind.profile?
23
+
24
+ summary = ViewBind::Profiler.summary
25
+ Rails.logger.info(summary) if summary
26
+ ViewBind::Profiler.reset
27
+ end
28
+
29
+ # A code reload rebuilds view classes and template caches; ours has to go with them.
30
+ # Without this an app that enables cache_template_loading in development would keep
31
+ # rendering the version of a partial it first resolved.
32
+ app.reloader.to_prepare { ViewBind.clear_cache }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewBind
4
+ # Teaches ActionView::Digestor about bind_render, so a fragment cache key still changes
5
+ # when a bound partial changes. Without this, editing a partial that is only reached
6
+ # through bind_render leaves every parent's `cache` key untouched and serves stale HTML.
7
+ #
8
+ # Only handlers this is registered for are tracked. ERB is registered automatically; for
9
+ # another template engine, call ViewBind::Tracker.register_for(:haml) in an initializer.
10
+ class Tracker
11
+ # Every public helper form: bind_render, bind_capture, bind_render_each,
12
+ # bind_render_memo, with or without parentheses. Missing one leaves a parent's fragment
13
+ # digest unchanged when the partial it names is edited.
14
+ #
15
+ # The path may only start a new line once a parenthesis has been opened. Allowing a bare
16
+ # line break would make a plain string literal on the line after an argument-less call
17
+ # look like that call's path.
18
+ # `#` cannot appear in a virtual path, so excluding it drops interpolated names such as
19
+ # "posts/#{kind}_card" rather than reporting a dependency that resolves to nothing. Rails'
20
+ # own tracker turns those into a "posts/*_card" wildcard; matching that is a larger job
21
+ # than this regex, and reporting nothing is the same answer it gives for a dynamic path.
22
+ DIRECTIVE = /\bbind_(?:render|capture)(?:_each|_memo)?(?:[ \t]*\(\s*|[ \t]+)["']([^"'#]+)["']/
23
+
24
+ # handler => the tracker that was registered before us, so we extend rather than replace
25
+ @wrapped = {}
26
+
27
+ class << self
28
+ attr_reader :wrapped
29
+
30
+ def supports_view_paths? = true
31
+
32
+ # Registers for `extension`, keeping whatever tracker was already registered for that
33
+ # handler so its dependencies are still reported. Another gem's custom ERB tracker
34
+ # must not disappear just because this gem loaded after it.
35
+ def register_for(extension)
36
+ require "action_view/dependency_tracker"
37
+ handler = ActionView::Template.handler_for_extension(extension)
38
+ @wrapped[handler] ||= existing_tracker_for(handler)
39
+ ActionView::DependencyTracker.register_tracker(extension, self)
40
+ end
41
+
42
+ def call(name, template, view_paths = nil)
43
+ inherited = wrapped[template.handler]
44
+ base = inherited || default_tracker
45
+ base.call(name, template, view_paths) | bound_dependencies(name, template)
46
+ end
47
+
48
+ # Rails' default for ERB. Named `:ruby` (AST) from Rails 8.1; 7.1 and 8.0 only ship the
49
+ # regex tracker, and ActionView.render_tracker does not exist there at all.
50
+ def default_tracker
51
+ # Reachable before the railtie's on_load hook has fired -- an app with eager_load
52
+ # off has not necessarily touched ActionView::DependencyTracker yet.
53
+ require "action_view/dependency_tracker"
54
+
55
+ if ActionView.respond_to?(:render_tracker) && ActionView.render_tracker == :ruby
56
+ ActionView::DependencyTracker::RubyTracker
57
+ else
58
+ ActionView::DependencyTracker::ERBTracker
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # The paths this template binds, resolved the way the renderer resolves them: a name
65
+ # with no slash is relative to the template's own directory, so `bind_render "card"`
66
+ # inside `audit/bound` depends on `audit/card`. Reported verbatim it names a partial
67
+ # the digestor cannot find, and the parent's fragment then survives an edit to the
68
+ # child. Rails' ERB and Ruby trackers normalise identically.
69
+ def bound_dependencies(name, template)
70
+ directory = name.split("/")[0..-2].join("/")
71
+ template.source.scan(DIRECTIVE).flatten.map do |path|
72
+ path.include?("/") ? path : "#{directory}/#{path}"
73
+ end
74
+ end
75
+
76
+ # DependencyTracker exposes no reader for a handler's tracker, so this reaches for the
77
+ # registry directly and falls back to the framework default if that ever changes.
78
+ def existing_tracker_for(handler)
79
+ registry = ActionView::DependencyTracker.instance_variable_get(:@trackers)
80
+ found = registry.respond_to?(:[]) ? registry[handler] : nil
81
+ found unless found == self
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewBind
4
+ VERSION = "0.1.0"
5
+ end
data/lib/view_bind.rb ADDED
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+ require_relative "view_bind/version"
5
+ require_relative "view_bind/helper"
6
+ require_relative "view_bind/tracker"
7
+ require_relative "view_bind/profiler"
8
+ require_relative "view_bind/railtie" if defined?(Rails::Railtie)
9
+
10
+ # ViewBind renders a partial by calling the method Rails already compiled for it,
11
+ # instead of walking the full `render` path on every call.
12
+ #
13
+ # What is skipped per call: the options hash, a fresh PartialRenderer, extract_details,
14
+ # the template lookup, the ActiveSupport notification, a per-partial OutputBuffer and the
15
+ # string copy out of it. What is NOT skipped: the partial is still an ordinary compiled
16
+ # template, so backtraces name the real file and line, `local_assigns` works, strict locals
17
+ # work, development reloading works and fragment cache digests still bust.
18
+ #
19
+ # Nothing here requires ActionView at load time: the gem may be required before Rails.
20
+ module ViewBind
21
+ # A resolved partial. `slow` means render it through ActionView::Template#render rather
22
+ # than by calling its compiled method: strict-locals partials (Template#render owns the
23
+ # argument checking and its error message), non-ERB handlers (which may return output
24
+ # instead of writing to the supplied buffer), and Rails whose internals we cannot reach.
25
+ # `writes_to_buffer` lets ERB keep sharing the caller's buffer on the slow path too.
26
+ Bound = Struct.new(:template, :method_name, :slow, :unbound_method, :writes_to_buffer)
27
+
28
+ # resolver context => virtual path => [[locals keys, Bound], ...]
29
+ #
30
+ # The context is everything `find_template` consults besides the path -- the details key
31
+ # (formats, locale, variants), the view paths and the prefixes -- plus the cache
32
+ # generation. Keying on the details key alone is not enough: two lookup contexts that differ
33
+ # only in view paths (a themed or tenant path, an engine, an override) or in prefixes (a
34
+ # relative partial name rendered from two controllers) share a details key and would
35
+ # otherwise share a template.
36
+ #
37
+ # The compiled method container is deliberately not part of it. A Bound holds the name of a
38
+ # method compiled into one container, and Rails maintains exactly one per details key:
39
+ # ActionView caches `DetailsKey.view_context_class`, and `DetailsKey.clear` drops that
40
+ # class, the resolver caches and every details key together (lookup_context.rb). A new
41
+ # container therefore always arrives with new details keys and new Templates. Stock `render`
42
+ # relies on the same invariant -- a Template compiles once, and a second container built by
43
+ # hand raises NoMethodError there too.
44
+ #
45
+ # A hit still allocates nothing: the composite key is built once per view per context and
46
+ # memoised on the view by #context_for, and the array scan below runs against the inner map.
47
+ CACHE = Concurrent::Map.new
48
+
49
+ # Bumped by .clear_cache so that a view which already memoised a bindings map stops using
50
+ # it. Clearing CACHE alone leaves such a view rendering the templates it resolved before.
51
+ @generation = 0
52
+
53
+ class << self
54
+ # Log one summary line per request instead of Rails' one line per partial, which bound
55
+ # partials no longer produce. Off by default; see ViewBind::Profiler.
56
+ attr_writer :profile
57
+
58
+ def profile? = @profile == true
59
+
60
+ def bound_for(view, path, keys)
61
+ # In development ActionView::Resolver.caching? is false: resolve every time so that
62
+ # edits to a partial are picked up without a restart.
63
+ return build(view, path, keys) unless ActionView::Resolver.caching?
64
+
65
+ by_path = bindings_for(view)
66
+ entries = by_path[path]
67
+ entries&.each { |keys_for_entry, bound| return bound if keys_for_entry == keys }
68
+
69
+ build(view, path, keys).tap do |bound|
70
+ # compute is atomic: two threads first-rendering the same partial with different
71
+ # locals cannot lose each other's entry.
72
+ by_path.compute(path) { |existing| (existing || []) + [[keys, bound]] }
73
+ end
74
+ end
75
+
76
+ # Same lookup, taking the locals hash instead of its keys: a hit compares against the
77
+ # cached key array in place, so the common path allocates nothing at all. `keys` is only
78
+ # materialised when the partial has to be resolved.
79
+ def bound_for_locals(view, path, locals)
80
+ return build(view, path, locals.keys) unless ActionView::Resolver.caching?
81
+
82
+ entries = bindings_for(view)[path]
83
+ entries&.each do |keys, bound|
84
+ return bound if keys.size == locals.size && keys.all? { |key| locals.key?(key) }
85
+ end
86
+
87
+ bound_for(view, path, locals.keys)
88
+ end
89
+
90
+ # This view's resolver context, memoised on the view itself: a request renders hundreds of
91
+ # partials through the same one, and re-deriving it per call costs more than the array
92
+ # scan it guards. Re-checked rather than assumed on every call, because a single view can
93
+ # switch formats, variants, view paths or prefixes part-way through a render.
94
+ #
95
+ # The last slot holds the bindings map, so #bindings_for is a memoised read too. Building
96
+ # the context does not touch CACHE, which lets the per-request memo in Helper key on it
97
+ # without populating a cache that development deliberately does not use.
98
+ def context_for(view)
99
+ lookup = view.lookup_context
100
+ cached = view.instance_variable_get(:@__view_bind_context)
101
+ if cached &&
102
+ cached[0].equal?(lookup.details_key) &&
103
+ cached[1].equal?(lookup.view_paths) &&
104
+ cached[2] == lookup.prefixes &&
105
+ cached[3] == @generation
106
+ return cached
107
+ end
108
+
109
+ # prefixes is a plain Array the caller owns; a copy is what makes the check above catch
110
+ # an in-place edit, and keeps the CACHE key from rotting under one.
111
+ context = [lookup.details_key, lookup.view_paths,
112
+ snapshot_prefixes(lookup.prefixes), @generation, nil]
113
+ view.instance_variable_set(:@__view_bind_context, context)
114
+ context
115
+ end
116
+
117
+ # The map of bindings resolved under this view's resolver context.
118
+ def bindings_for(view)
119
+ context = context_for(view)
120
+ context[4] ||= CACHE.fetch_or_store(context[0, 4]) { Concurrent::Map.new }
121
+ end
122
+
123
+ # Drops every resolved template. The railtie hooks this to ActiveSupport::Reloader, so
124
+ # a code reload cannot leave a stale template behind even in an app that turns
125
+ # `cache_template_loading` on in development.
126
+ def clear_cache
127
+ # Views alive across the clear hold a memoised context pointing at a map that is about
128
+ # to be emptied; the generation is what makes them rebuild instead of reusing it.
129
+ @generation += 1
130
+ CACHE.clear
131
+ end
132
+
133
+ # The fast path calls three of ActionView::Template's :nodoc: methods. If a future Rails
134
+ # renames one, every partial quietly goes back through Template#render instead of
135
+ # raising NoMethodError on the first request after the upgrade.
136
+ def fast_path_available?
137
+ return @fast_path_available unless @fast_path_available.nil?
138
+
139
+ @fast_path_available = %i[compile! method_name handle_render_error].all? do |method|
140
+ ActionView::Template.private_method_defined?(method) ||
141
+ ActionView::Template.method_defined?(method)
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ # Copies the array *and* its strings. A shallow dup shares the elements, so a caller that
148
+ # mutates a prefix in place -- `prefix.replace("beta")` rather than assigning a new array
149
+ # -- would change this snapshot along with the live one, the check in #context_for would
150
+ # see no difference, and the view would go on rendering the partial it first resolved.
151
+ # The CACHE key holds these strings too, so they have to stop moving.
152
+ #
153
+ # nil passes straight through: `prefixes` is a public accessor and Rails resolves a name
154
+ # against a nil prefix list perfectly well (see LookupContext#normalize_name), so this
155
+ # must not be where that stops working.
156
+ def snapshot_prefixes(prefixes)
157
+ prefixes&.map { |prefix| prefix.frozen? ? prefix : prefix.dup.freeze }
158
+ end
159
+
160
+ def build(view, path, keys)
161
+ template = resolve(view, path, keys)
162
+ # Only the stock ERB handler is known to append to the caller's buffer. Other
163
+ # handlers, including raw and static HTML, can return a string or a new buffer.
164
+ writes_to_buffer = template.handler.instance_of?(ActionView::Template::Handlers::ERB)
165
+ if template.strict_locals? || !writes_to_buffer || !fast_path_available?
166
+ return Bound.new(template, nil, true, nil, writes_to_buffer)
167
+ end
168
+
169
+ template.send(:compile!, view)
170
+ method_name = template.send(:method_name)
171
+ # bind_call on the UnboundMethod dispatches faster than public_send, and the method
172
+ # lives on the container, so it can be looked up once here rather than per call.
173
+ Bound.new(template, method_name, false,
174
+ view.compiled_method_container.instance_method(method_name), true)
175
+ end
176
+
177
+ def resolve(view, path, keys)
178
+ prefix, _, name = path.rpartition("/")
179
+ prefixes = prefix.empty? ? view.lookup_context.prefixes : [prefix]
180
+ # find_template raises ActionView::MissingTemplate itself; there is no nil to guard.
181
+ view.lookup_context.find_template(name, prefixes, true, keys, {})
182
+ end
183
+ end
184
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: view_bind
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Igor Kasyanchuk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionview
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: concurrent-ruby
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.1'
54
+ description: 'bind_render and bind_render_each resolve a partial once and then call
55
+ the method ActionView already compiled for it. Partials stay ordinary partials:
56
+ backtraces, locals, strict locals, development reloading and fragment cache digests
57
+ all keep working.'
58
+ email:
59
+ - igorkasyanchuk@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - LICENSE.txt
66
+ - README.md
67
+ - lib/view_bind.rb
68
+ - lib/view_bind/helper.rb
69
+ - lib/view_bind/profiler.rb
70
+ - lib/view_bind/railtie.rb
71
+ - lib/view_bind/tracker.rb
72
+ - lib/view_bind/version.rb
73
+ homepage: https://github.com/igorkasyanchuk/view_bind
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ source_code_uri: https://github.com/igorkasyanchuk/view_bind
78
+ changelog_uri: https://github.com/igorkasyanchuk/view_bind/blob/main/CHANGELOG.md
79
+ rubygems_mfa_required: 'true'
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.1.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.7.2
95
+ specification_version: 4
96
+ summary: Render Rails partials by calling their compiled method, skipping the per-call
97
+ render machinery.
98
+ test_files: []