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 +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +365 -0
- data/lib/view_bind/helper.rb +319 -0
- data/lib/view_bind/profiler.rb +91 -0
- data/lib/view_bind/railtie.rb +35 -0
- data/lib/view_bind/tracker.rb +87 -0
- data/lib/view_bind/version.rb +5 -0
- data/lib/view_bind.rb +184 -0
- metadata +98 -0
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
|
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: []
|