style_capsule 2.0.3 → 2.0.4
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 +4 -4
- data/CHANGELOG.md +11 -1
- data/README.md +28 -26
- data/docs/changelogs/20261107115010_release-2-0-4.md +24 -0
- data/docs/non_rails_support.md +8 -0
- data/docs/reviews/20261106210000_pressure-optimizations-and-c-ext.md +65 -0
- data/docs/reviews/20261107114500_fiber-isolation-experimental.md +39 -0
- data/docs/reviews/20261107115318_prose-audit-markdown-cleanup.md +41 -0
- data/lib/style_capsule/component_styles_support.rb +3 -0
- data/lib/style_capsule/css_processor.rb +69 -27
- data/lib/style_capsule/execution_state.rb +68 -0
- data/lib/style_capsule/helper_scope_cache.rb +3 -2
- data/lib/style_capsule/instrumentation.rb +13 -97
- data/lib/style_capsule/stylesheet_registry.rb +57 -40
- data/lib/style_capsule/version.rb +1 -1
- data/lib/style_capsule.rb +18 -0
- data/sig/style_capsule.rbs +14 -0
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dac0417ef84666945aab821ea7e93da52c7ced67fde8cf402542606cffc47038
|
|
4
|
+
data.tar.gz: 4177a0af231cb8353dccb62cf468eb039f2a832e2cbf766755a4ed668f1efeaf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 920098a4730dac31891323e76856a825d33643f57b445a2908d67005efa3f2435539e8ce820696b742cabdb39afe4b958e96d0956248e3d7f689e6708bb44ca1
|
|
7
|
+
data.tar.gz: 71be39fe69de8e882cb5484633f53a0560652245806972307e5ac014659f9a47546b257c784e84f5bb08f8f2a28caa35e4c045f420aa8ddb9f4b86ab89f0a19a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 2.0.4 (2026-11-07)
|
|
4
|
+
|
|
5
|
+
- Experimental: add `StyleCapsule.isolation_level = :fiber` so request-scoped stylesheet registry state and the helper scope cache isolate per Fiber (`:thread` remains default)
|
|
6
|
+
- Remove temporary benchmark feature toggles; ship the optimized CSS and stylesheet-registry paths as the only behavior
|
|
7
|
+
- `Instrumentation`: skip `ActiveSupport::Notifications` work unless a subscriber exists on the event (hot path avoids notifier setup when nothing listens)
|
|
8
|
+
- `CssProcessor`: skip comment stripping when there are no `/*` comments; fast ASCII whitespace scan in `patch_selectors`; length-before-regex capsule validation; frozen breakout regex; `split(",", -1)` for comma lists; direct nesting output when hoisting at-rules
|
|
9
|
+
- `CssProcessor#patch_selectors`: reuse one `scoped_prefix` buffer; skip comma split/join when a prelude has a single selector
|
|
10
|
+
- `StylesheetRegistry`: append inline styles in place; buffered `<head>` tag rendering; reusable inline `<style>` open/close fragments
|
|
11
|
+
- Maintainer load harness: `benchmarks/load.rb` + `make bench-load` / `make bench-fiber` (wall, CPU, allocations - no library toggles)
|
|
12
|
+
|
|
3
13
|
## 2.0.3 (2026-11-03)
|
|
4
14
|
|
|
5
15
|
- Leave `@keyframes` / `@font-face` / `@property` steps unscoped under selector patching
|
|
@@ -100,7 +110,7 @@
|
|
|
100
110
|
|
|
101
111
|
- Update ViewComponent dependency to version 4.0 and adjust compatibility in tests
|
|
102
112
|
- Enhance error handling in style_capsule Rake task for ViewComponent loading issues
|
|
103
|
-
- Remove Rails 6 support from Appraisals file and
|
|
113
|
+
- Remove Rails 6 support from Appraisals file and adjust Rails 7.2 dependencies
|
|
104
114
|
- Update Rails and ActiveSupport version requirements
|
|
105
115
|
- Add Codecov badge to README
|
|
106
116
|
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# style_capsule
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/style_capsule) [](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml) [](https://app.codecov.io/github/amkisko/style_capsule.rb)
|
|
4
4
|
|
|
5
5
|
CSS scoping for Ruby components. Attribute selectors encapsulate styles for Phlex, ViewComponent, and ERB so rules stay inside the component wrapper. Works with Rails and standalone in other Ruby frameworks (Sinatra, Hanami, and similar) or plain Ruby. Cache strategies: none, time-based, custom proc, and file-based.
|
|
6
6
|
|
|
@@ -84,7 +84,7 @@ end
|
|
|
84
84
|
<% end %>
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
With custom wrapper tag:
|
|
88
88
|
|
|
89
89
|
```erb
|
|
90
90
|
<%= style_capsule(tag: :section) do %>
|
|
@@ -99,18 +99,18 @@ end
|
|
|
99
99
|
|
|
100
100
|
StyleCapsule supports two CSS scoping strategies:
|
|
101
101
|
|
|
102
|
-
1.
|
|
102
|
+
1. Selector Patching (default): Adds `[data-capsule="..."]` prefix to each selector
|
|
103
103
|
- Better browser support (all modern browsers)
|
|
104
104
|
- Output: `[data-capsule="abc123"] .section { color: red; }`
|
|
105
105
|
|
|
106
|
-
2.
|
|
106
|
+
2. CSS Nesting (optional): Wraps nestable rules in `[data-capsule="..."] { ... }`
|
|
107
107
|
- Skips per-selector rewrite; hoists `@keyframes` / `@font-face` and similar to top level
|
|
108
108
|
- Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)
|
|
109
109
|
- Output: `[data-capsule="abc123"] { .section { color: red; } }`
|
|
110
110
|
|
|
111
111
|
### Configuration
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
Per-component (using `style_capsule` - recommended):
|
|
114
114
|
|
|
115
115
|
```ruby
|
|
116
116
|
class MyComponent < ApplicationComponent
|
|
@@ -119,7 +119,7 @@ class MyComponent < ApplicationComponent
|
|
|
119
119
|
end
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
With custom wrapper tag:
|
|
123
123
|
|
|
124
124
|
```ruby
|
|
125
125
|
class MyComponent < ApplicationComponent
|
|
@@ -128,7 +128,7 @@ class MyComponent < ApplicationComponent
|
|
|
128
128
|
end
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
Global (in base component class):
|
|
132
132
|
|
|
133
133
|
```ruby
|
|
134
134
|
class ApplicationComponent < Phlex::HTML
|
|
@@ -137,7 +137,7 @@ class ApplicationComponent < Phlex::HTML
|
|
|
137
137
|
end
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
Note: If you change the strategy and it doesn't take effect, clear the CSS cache:
|
|
141
141
|
|
|
142
142
|
```ruby
|
|
143
143
|
MyComponent.clear_css_cache
|
|
@@ -196,20 +196,20 @@ head do
|
|
|
196
196
|
end
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
Namespace Isolation: Using namespaces prevents stylesheet leakage between different application contexts. For example, login pages can use `namespace: :login`, ActiveAdmin can use `namespace: :active_admin`, and user components can use `namespace: :user`. Each namespace is rendered separately, improving caching efficiency and preventing style conflicts.
|
|
200
200
|
|
|
201
201
|
### Registering Stylesheet Files
|
|
202
202
|
|
|
203
203
|
You can also register external stylesheet files (not inline CSS) for head rendering. When a component has a configured namespace via `style_capsule`, you don't need to specify it every time:
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
In ERB:
|
|
206
206
|
|
|
207
207
|
```erb
|
|
208
208
|
<% register_stylesheet("stylesheets/user/my_component", "data-turbo-track": "reload") %>
|
|
209
209
|
<% register_stylesheet("stylesheets/admin/dashboard", namespace: :admin) %>
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
In Phlex (requires including `StyleCapsule::PhlexHelper`):
|
|
213
213
|
|
|
214
214
|
```ruby
|
|
215
215
|
class UserComponent < ApplicationComponent
|
|
@@ -227,7 +227,7 @@ class UserComponent < ApplicationComponent
|
|
|
227
227
|
end
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
In ViewComponent (requires including `StyleCapsule::ViewComponentHelper`):
|
|
231
231
|
|
|
232
232
|
```ruby
|
|
233
233
|
class UserComponent < ApplicationComponent
|
|
@@ -245,7 +245,7 @@ end
|
|
|
245
245
|
|
|
246
246
|
Registered files are rendered via `stylesheet_registry_tags` in your layout, just like inline CSS. The namespace is automatically used from the component's `style_capsule` configuration when not explicitly specified.
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
Boot-time file paths: register static paths once with `StyleCapsule::StylesheetRegistry.register_eager(...)` (initializers, class load). Use `register` / `register_stylesheet` during rendering for request-scoped paths.
|
|
249
249
|
|
|
250
250
|
### Late head injection (Rails)
|
|
251
251
|
|
|
@@ -290,11 +290,11 @@ style_capsule cache_strategy: ->(css, capsule_id, namespace) {
|
|
|
290
290
|
}
|
|
291
291
|
```
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
Note: `cache_strategy` accepts Symbol (`:time`), String (`"time"`), or Proc. Strings are automatically converted to symbols.
|
|
294
294
|
|
|
295
295
|
### File-Based Caching (HTTP Caching)
|
|
296
296
|
|
|
297
|
-
Writes CSS to files for HTTP caching.
|
|
297
|
+
Writes CSS to files for HTTP caching. Requires class method `def self.component_styles`:
|
|
298
298
|
|
|
299
299
|
```ruby
|
|
300
300
|
class MyComponent < ApplicationComponent
|
|
@@ -310,7 +310,7 @@ class MyComponent < ApplicationComponent
|
|
|
310
310
|
end
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
-
|
|
313
|
+
Configuration:
|
|
314
314
|
|
|
315
315
|
```ruby
|
|
316
316
|
# config/initializers/style_capsule.rb
|
|
@@ -323,9 +323,9 @@ StyleCapsule::CssFileWriter.configure(
|
|
|
323
323
|
)
|
|
324
324
|
```
|
|
325
325
|
|
|
326
|
-
|
|
326
|
+
Fallback Directory: When the primary output directory is not writable (for example a read-only app tree in a container), writes go under a per-user directory in `Dir.tmpdir`. If that write also fails, rendering uses inline CSS.
|
|
327
327
|
|
|
328
|
-
|
|
328
|
+
Precompilation:
|
|
329
329
|
|
|
330
330
|
```bash
|
|
331
331
|
bin/rails style_capsule:build # Build CSS files
|
|
@@ -334,7 +334,7 @@ bin/rails style_capsule:clear # Clear generated files
|
|
|
334
334
|
|
|
335
335
|
Files are automatically built during `bin/rails assets:precompile`.
|
|
336
336
|
|
|
337
|
-
|
|
337
|
+
Compatibility: The stylesheet registry works with Propshaft, Sprockets, and other Rails asset bundlers. Static file paths are collected in a process-wide manifest (similar to Propshaft's approach), while inline CSS is stored per-request.
|
|
338
338
|
|
|
339
339
|
## Instrumentation
|
|
340
340
|
|
|
@@ -435,7 +435,7 @@ class Theme < ApplicationRecord
|
|
|
435
435
|
end
|
|
436
436
|
```
|
|
437
437
|
|
|
438
|
-
|
|
438
|
+
Usage:
|
|
439
439
|
|
|
440
440
|
```erb
|
|
441
441
|
<div data-capsule="<%= theme.capsule_id %>">
|
|
@@ -458,20 +458,22 @@ end
|
|
|
458
458
|
- Rails >= 7.0, < 9.0 (optional, for Rails integration)
|
|
459
459
|
- ActiveSupport >= 7.0, < 9.0 (optional, for Rails integration)
|
|
460
460
|
|
|
461
|
-
|
|
461
|
+
Note: The gem works without Rails. See [Non-Rails Support](#non-rails-support) below.
|
|
462
462
|
|
|
463
463
|
## Non-Rails Support
|
|
464
464
|
|
|
465
465
|
The core library is framework-agnostic. Without Rails, use `StyleCapsule::CssProcessor` directly, `StyleCapsule::Component` with Phlex, or `StyleCapsule::StandaloneHelper` for ERB-style templates. The stylesheet registry falls back to thread-local storage when `ActiveSupport::CurrentAttributes` is not loaded.
|
|
466
466
|
|
|
467
|
-
|
|
467
|
+
Experimental Fiber isolation: `StyleCapsule.isolation_level = :fiber` isolates request-scoped stylesheet registry state and the helper scope cache per Fiber (for Fiber servers / interleaved Fibers on one Thread). It does not flip Rails' own `config.active_support.isolation_level`; Falcon-style apps usually set both.
|
|
468
|
+
|
|
469
|
+
For setup examples, API notes, and the relationship between `style_capsule` and `stylesheet_registry`, see [docs/non_rails_support.md](docs/non_rails_support.md).
|
|
468
470
|
|
|
469
471
|
## How It Works
|
|
470
472
|
|
|
471
|
-
1.
|
|
472
|
-
2.
|
|
473
|
-
3.
|
|
474
|
-
4.
|
|
473
|
+
1. Scope ID Generation: Each component class gets a unique scope ID based on its class name (shared across all instances)
|
|
474
|
+
2. CSS Rewriting: CSS selectors gain `[data-capsule="..."]` attribute selectors (or a nesting wrapper)
|
|
475
|
+
3. HTML Wrapping: Component content is wrapped in a scoped element
|
|
476
|
+
4. Class names: Class names in CSS and markup stay as written
|
|
475
477
|
|
|
476
478
|
## Development
|
|
477
479
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Release 2.0.4
|
|
2
|
+
|
|
3
|
+
## Participants
|
|
4
|
+
|
|
5
|
+
Andrei Makarov (@amkisko)
|
|
6
|
+
|
|
7
|
+
## Decisions
|
|
8
|
+
|
|
9
|
+
Cut 2.0.4 as a patch. Ship experimental `StyleCapsule.isolation_level = :fiber` (default stays `:thread`) and keep the CSS / stylesheet-registry / instrumentation hot-path optimizations as the only behavior. Do not flip Rails `ActiveSupport::IsolatedExecutionState.isolation_level` from this API. Keep the experimental label until a production Fiber server validates the API. No custom C extension.
|
|
10
|
+
|
|
11
|
+
## Effects
|
|
12
|
+
|
|
13
|
+
VERSION is 2.0.4. CHANGELOG section dated 2026-11-07. PATH gem entries in `Gemfile.lock` and appraisal lockfiles point at 2.0.4. New `lib/style_capsule/execution_state.rb`, fiber isolation specs, maintainer `benchmarks/load.rb`, and review notes ship in the repo; `benchmarks/output/` stays gitignored. Gem package builds and verifies at 58368 compressed bytes across 40 packaged paths. RuboCop clean (60 files). Default suite: 514 examples, 0 failures.
|
|
14
|
+
|
|
15
|
+
## Next
|
|
16
|
+
|
|
17
|
+
Push `main`, then run `make release` to test, push the gem, tag `2.0.4`, and create the GitHub release. RubyGems still serves 2.0.3 until publish.
|
|
18
|
+
|
|
19
|
+
## Source
|
|
20
|
+
|
|
21
|
+
- `docs/reviews/20261107114500_fiber-isolation-experimental.md`
|
|
22
|
+
- `docs/reviews/20261106210000_pressure-optimizations-and-c-ext.md`
|
|
23
|
+
- `CHANGELOG.md` section 2.0.4
|
|
24
|
+
- `lib/style_capsule/version.rb`
|
data/docs/non_rails_support.md
CHANGED
|
@@ -65,6 +65,14 @@ end
|
|
|
65
65
|
|
|
66
66
|
When `ActiveSupport::CurrentAttributes` is not loaded, `StyleCapsule::StylesheetRegistry` uses thread-local storage for request-scoped inline CSS and render-time file paths. Use `register_eager` for process-wide file paths that should appear on every request (boot or class load). Use `register` during rendering when a component calls `register_stylesheet`.
|
|
67
67
|
|
|
68
|
+
Experimental Fiber isolation (opt-in; default remains `:thread`):
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
StyleCapsule.isolation_level = :fiber
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
With `:fiber`, request-scoped registry state and the helper scope cache live on the current Fiber. This does not change Rails `config.active_support.isolation_level`.
|
|
75
|
+
|
|
68
76
|
```ruby
|
|
69
77
|
require "style_capsule"
|
|
70
78
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Pressure optimizations and C-extension probe
|
|
2
|
+
|
|
3
|
+
## Participants
|
|
4
|
+
|
|
5
|
+
Andrei Makarov (@amkisko)
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
`style_capsule.rb` after the pressure-cooker episode: remove pressure feature flags from `lib/`, keep permanent CSS/registry optimizations, throwaway C prefix probe (not shipped).
|
|
10
|
+
|
|
11
|
+
Host for cited numbers: Ruby 4.0.x, arm64-darwin. Host-relative, not SLOs.
|
|
12
|
+
|
|
13
|
+
## Decision: C extension
|
|
14
|
+
|
|
15
|
+
Ship custom C extension: no.
|
|
16
|
+
|
|
17
|
+
Probe: `CssProcessor.patch_selectors` on about 259 KiB CSS. Ruby median wall 0.0444 s. Simple C prefix median wall 0.00102 s (about 44x). Do not ship: the C stub is not a semantic substitute (`:host`, comments, at-rules, validation).
|
|
18
|
+
|
|
19
|
+
Throwaway tree: `benchmarks/tmp_c_ext_probe/` (removed after measurement).
|
|
20
|
+
|
|
21
|
+
## When to try C extensions again
|
|
22
|
+
|
|
23
|
+
Retry custom C only when measurement shows the hot path is (1) a large enough share of total time and (2) a tight loop over primitives with full CSS semantics.
|
|
24
|
+
|
|
25
|
+
### Try again when
|
|
26
|
+
|
|
27
|
+
- Work is linear over CSS bytes in one rare boundary call (whole stylesheet in, scoped CSS out).
|
|
28
|
+
- The C implementation is a full semantic substitute for `CssProcessor` (same `:host`, comments, at-rules, validation).
|
|
29
|
+
- End-to-end `scope_selectors` / head-render improvement is clearly above about 20% median wall (n >= 3, same load). A prefix-only stub that is about 44x faster is not enough.
|
|
30
|
+
- Profile shows roughly 30-40% or more of time in one tight function before writing `ext/`.
|
|
31
|
+
|
|
32
|
+
### Do not retry when
|
|
33
|
+
|
|
34
|
+
- The win is only a simplified prefix stub without production CSS rules.
|
|
35
|
+
- The bottleneck is allocation, GC, or Rails notifications, not the scanner loop.
|
|
36
|
+
- Platform/ABI/maintenance cost exceeds the measured gain.
|
|
37
|
+
|
|
38
|
+
### Gate before shipping
|
|
39
|
+
|
|
40
|
+
1. Load measure: named primary series (`wall_s` / `storage_delta_bytes`) dominates at L1.
|
|
41
|
+
2. Profile: one tight function owns a large fraction of CSS processing time.
|
|
42
|
+
3. Throwaway C probe with the same semantics, then add `ext/` only if median wall drops enough on the full processor probe and tests stay green.
|
|
43
|
+
|
|
44
|
+
Short form: retry C for a full CSS byte loop; skip C for hashes, notifications, and partial stubs.
|
|
45
|
+
|
|
46
|
+
## Permanent Ruby optimizations (flags removed)
|
|
47
|
+
|
|
48
|
+
Observed during cooker / load:
|
|
49
|
+
|
|
50
|
+
- Skip `ActiveSupport::Notifications` when no subscribers: less instrument overhead on CSS path.
|
|
51
|
+
- Comment strip only if `/*` present: avoids useless scan.
|
|
52
|
+
- `patch_selectors`: shared `scoped_prefix`, single-selector fast path, ASCII whitespace loop, `split(",", -1)`: lower alloc/string churn on large sheets.
|
|
53
|
+
- Capsule id length check before regex; frozen breakout pattern: cheaper validation.
|
|
54
|
+
- Registry in-place updates; buffered head render; frozen `<style>` fragments: less hash rebuild on register/render.
|
|
55
|
+
|
|
56
|
+
`StyleCapsule.pressure_opt` removed; optimized branches are unconditional. Specs: 508 examples green after cleanup.
|
|
57
|
+
|
|
58
|
+
## Documentation cleanup
|
|
59
|
+
|
|
60
|
+
Removed cooker episode docs, `pressure_run` / cooker harnesses, and joint multi-gem review content from this repository. Retained light `benchmarks/load.rb`.
|
|
61
|
+
|
|
62
|
+
## Source
|
|
63
|
+
|
|
64
|
+
- `lib/style_capsule/` permanent paths
|
|
65
|
+
- Throwaway C prefix probe numbers above (trees deleted)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Experimental Fiber isolation review
|
|
2
|
+
|
|
3
|
+
## Participants
|
|
4
|
+
|
|
5
|
+
Andrei Makarov (@amkisko)
|
|
6
|
+
|
|
7
|
+
## Decisions
|
|
8
|
+
|
|
9
|
+
Ship experimental `StyleCapsule.isolation_level = :fiber`. Default stays `:thread`. Fiber mode stores request-scoped stylesheet registry state (inline CSS and render-time file paths) and the helper scope cache on the current Fiber. When `:fiber` is set, the registry bypasses `ActiveSupport::CurrentAttributes` for those keys so interleaved Fibers on one Thread do not share request bag state. Do not flip Rails `ActiveSupport::IsolatedExecutionState.isolation_level` from this API; Falcon-style apps set Rails isolation separately.
|
|
10
|
+
|
|
11
|
+
Accept the feature on measured baselines (n = 3 samples, `SCENARIO=fiber`, `BENCH_FIBER_N=3000`, `BENCH_FIBER_COUNT=8`, Ruby 4.0.7 arm64-darwin). Keep the experimental label until a production Fiber server validates the API.
|
|
12
|
+
|
|
13
|
+
## Effects
|
|
14
|
+
|
|
15
|
+
Implementation: `lib/style_capsule/execution_state.rb`, wired from `StylesheetRegistry` request stores and `HelperScopeCache`. Specs cover concurrent Fiber isolation, sibling non-leak, and shared-stack behavior under `:thread`. `make bench-fiber` / `SCENARIO=fiber` covers sequential Fiber, concurrent Fiber under `:fiber`, and concurrent Fibers under default `:thread`.
|
|
16
|
+
|
|
17
|
+
Median of three host samples (`benchmarks/output/fiber_sample_{1,2,3}.log`):
|
|
18
|
+
|
|
19
|
+
- `baseline_fiber_sequential`: median ops/s 200561.6, median wall 0.0150 s, alloc 81056
|
|
20
|
+
- `baseline_fiber_concurrent`: median ops/s 174286.9, median wall 0.0172 s, alloc 103165
|
|
21
|
+
- `baseline_thread_concurrent_fibers`: median ops/s 162610.4, median wall 0.0184 s, alloc 97118
|
|
22
|
+
|
|
23
|
+
Fiber concurrent isolation stays in the same band as the shared-stack `:thread` concurrent Fiber path (about 1.07x median ops/s). Sequential Fiber register/render remains the fastest of the three series.
|
|
24
|
+
|
|
25
|
+
## Next
|
|
26
|
+
|
|
27
|
+
- Keep experimental label until a production Fiber server validates the API.
|
|
28
|
+
- Optional later: calibrate pressure ceilings for fiber series if a hot path regresses.
|
|
29
|
+
|
|
30
|
+
## Source
|
|
31
|
+
|
|
32
|
+
- `lib/style_capsule/execution_state.rb`
|
|
33
|
+
- `lib/style_capsule/stylesheet_registry.rb`
|
|
34
|
+
- `lib/style_capsule/helper_scope_cache.rb`
|
|
35
|
+
- `spec/style_capsule/fiber_isolation_spec.rb`
|
|
36
|
+
- `benchmarks/load.rb` (`SCENARIO=fiber`)
|
|
37
|
+
- `benchmarks/output/fiber_sample_1.log`
|
|
38
|
+
- `benchmarks/output/fiber_sample_2.log`
|
|
39
|
+
- `benchmarks/output/fiber_sample_3.log`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Prose audit: markdown cleanup
|
|
2
|
+
|
|
3
|
+
## Participants
|
|
4
|
+
|
|
5
|
+
Andrei Makarov (@amkisko)
|
|
6
|
+
|
|
7
|
+
## Decisions
|
|
8
|
+
|
|
9
|
+
Apply writing-prose / AGENTS plain-prose rules beyond docs timestamp trees for product and maintainer markdown that still used bold, italic, or non-ASCII punctuation for emphasis. Keep headings, lists, links, badges, and fenced code. Prefer ASCII punctuation (`-`, `->`, `>=`, straight quotes).
|
|
10
|
+
|
|
11
|
+
## Effects
|
|
12
|
+
|
|
13
|
+
Timestamp trees (earlier in this note's first cut):
|
|
14
|
+
|
|
15
|
+
- `docs/reviews/20261107114500_fiber-isolation-experimental.md`: labelled bullets instead of a median table; no bold or multiplication-sign glyph.
|
|
16
|
+
- `docs/reviews/20261106210000_pressure-optimizations-and-c-ext.md`: prose and bullets instead of C-probe and optimization tables; no bold, Unicode arrow, curly quotes, or comparison glyphs.
|
|
17
|
+
|
|
18
|
+
Extended pass:
|
|
19
|
+
|
|
20
|
+
- `README.md`: removed bold labels and emphasis; gem badge query bumped to 2.0.4; softened the "without Rails" note.
|
|
21
|
+
- `CHANGELOG.md`: replaced em dash in the 2.0.4 load-harness bullet with ASCII `-`.
|
|
22
|
+
- `spec/README.md`: removed bold guideline labels.
|
|
23
|
+
- `.github/pull_request_template.md`: removed italic intro and bold checklist labels.
|
|
24
|
+
|
|
25
|
+
Already clean (no change): `docs/non_rails_support.md`, `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `GOVERNANCE.md`, `LICENSE.md`, `rfcs/README.md`.
|
|
26
|
+
|
|
27
|
+
## Next
|
|
28
|
+
|
|
29
|
+
- Keep new docs notes in labelled lists rather than tables.
|
|
30
|
+
- Leave badge images and hyperlinks as the only non-prose chrome in README.
|
|
31
|
+
|
|
32
|
+
## Source
|
|
33
|
+
|
|
34
|
+
- AGENTS.md Writing and changelog prose checks
|
|
35
|
+
- writing-prose skill
|
|
36
|
+
- `README.md`
|
|
37
|
+
- `CHANGELOG.md`
|
|
38
|
+
- `spec/README.md`
|
|
39
|
+
- `.github/pull_request_template.md`
|
|
40
|
+
- `docs/reviews/20261107114500_fiber-isolation-experimental.md`
|
|
41
|
+
- `docs/reviews/20261106210000_pressure-optimizations-and-c-ext.md`
|
|
@@ -53,7 +53,10 @@ module StyleCapsule
|
|
|
53
53
|
|
|
54
54
|
component_class.instance_variable_set(:@_style_capsule_file_cache_warned, true)
|
|
55
55
|
label = component_class.name
|
|
56
|
+
# Class#name can be nil/empty for anonymous classes; avoid ActiveSupport blank?
|
|
57
|
+
# rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
|
|
56
58
|
label = component_class.inspect if label.nil? || label.empty?
|
|
59
|
+
# rubocop:enable Rails/Blank
|
|
57
60
|
warn "[StyleCapsule] :file cache strategy requires class-method component_styles; " \
|
|
58
61
|
"falling back to :none for #{label}"
|
|
59
62
|
end
|
|
@@ -18,6 +18,10 @@ module StyleCapsule
|
|
|
18
18
|
# Maximum CSS content size (1MB) to prevent DoS attacks
|
|
19
19
|
MAX_CSS_SIZE = 1_000_000
|
|
20
20
|
|
|
21
|
+
CAPSULE_ID_PATTERN = /\A[a-zA-Z0-9_-]+\z/
|
|
22
|
+
|
|
23
|
+
STYLE_ELEMENT_BREAKOUT_PATTERN = %r{</style}i
|
|
24
|
+
|
|
21
25
|
# At-rules whose inner selectors must not receive attribute scoping
|
|
22
26
|
NON_SCOPING_AT_RULES = %w[keyframes font-face property].freeze
|
|
23
27
|
|
|
@@ -61,7 +65,13 @@ module StyleCapsule
|
|
|
61
65
|
css_content: css_string
|
|
62
66
|
) do
|
|
63
67
|
capsule_attr = %([data-capsule="#{capsule_id}"])
|
|
64
|
-
|
|
68
|
+
css_for_patch =
|
|
69
|
+
if css_string.include?("/*")
|
|
70
|
+
strip_comments(css_string.dup)
|
|
71
|
+
else
|
|
72
|
+
css_string
|
|
73
|
+
end
|
|
74
|
+
patch_selectors(css_for_patch, capsule_attr)
|
|
65
75
|
end
|
|
66
76
|
end
|
|
67
77
|
|
|
@@ -105,10 +115,14 @@ module StyleCapsule
|
|
|
105
115
|
) do
|
|
106
116
|
capsule_attr = %([data-capsule="#{capsule_id}"])
|
|
107
117
|
hoisted, nestable = hoist_top_level_at_rules(css_string)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
nest_stripped = nestable.strip
|
|
119
|
+
if nest_stripped.empty?
|
|
120
|
+
hoisted
|
|
121
|
+
elsif hoisted.empty?
|
|
122
|
+
"#{capsule_attr} {\n#{nest_stripped}\n}"
|
|
123
|
+
else
|
|
124
|
+
"#{hoisted}\n#{capsule_attr} {\n#{nest_stripped}\n}"
|
|
125
|
+
end
|
|
112
126
|
end
|
|
113
127
|
end
|
|
114
128
|
# rubocop:enable Metrics/AbcSize
|
|
@@ -118,6 +132,8 @@ module StyleCapsule
|
|
|
118
132
|
# @param css [String] CSS content with comments
|
|
119
133
|
# @return [String] CSS content without comments
|
|
120
134
|
def self.strip_comments(css)
|
|
135
|
+
return css unless css.include?("/*")
|
|
136
|
+
|
|
121
137
|
css.gsub(/\/\*.*?\*\//m, "")
|
|
122
138
|
end
|
|
123
139
|
|
|
@@ -133,7 +149,7 @@ module StyleCapsule
|
|
|
133
149
|
# rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
|
|
134
150
|
return if css_string.nil? || css_string.empty?
|
|
135
151
|
# rubocop:enable Rails/Blank
|
|
136
|
-
return unless css_string.match?(
|
|
152
|
+
return unless css_string.match?(STYLE_ELEMENT_BREAKOUT_PATTERN)
|
|
137
153
|
|
|
138
154
|
raise ArgumentError, "CSS content must not contain a style element closer"
|
|
139
155
|
end
|
|
@@ -151,25 +167,31 @@ module StyleCapsule
|
|
|
151
167
|
raise ArgumentError, "capsule_id cannot be empty"
|
|
152
168
|
end
|
|
153
169
|
|
|
154
|
-
unless capsule_id.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
155
|
-
raise ArgumentError, "Invalid capsule_id: must contain only alphanumeric characters, hyphens, and underscores (got: #{capsule_id.inspect})"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
170
|
if capsule_id.length > 100
|
|
159
171
|
raise ArgumentError, "Invalid capsule_id: too long (max 100 characters, got #{capsule_id.length})"
|
|
160
172
|
end
|
|
173
|
+
|
|
174
|
+
unless capsule_id.match?(CAPSULE_ID_PATTERN)
|
|
175
|
+
raise ArgumentError, "Invalid capsule_id: must contain only alphanumeric characters, hyphens, and underscores (got: #{capsule_id.inspect})"
|
|
176
|
+
end
|
|
161
177
|
end
|
|
162
178
|
|
|
163
179
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- walks braces and at-rules in one pass
|
|
164
180
|
def self.patch_selectors(css, capsule_attr)
|
|
165
181
|
output = +""
|
|
182
|
+
# Reuse one prefix string for the common `[data-capsule="..."] ` case to avoid
|
|
183
|
+
# repeated interpolation when thousands of selectors are comma-split.
|
|
184
|
+
scoped_prefix = "#{capsule_attr} "
|
|
166
185
|
index = 0
|
|
167
186
|
length = css.length
|
|
168
187
|
brace_depth = 0
|
|
169
188
|
suppress_below_depth = nil
|
|
170
189
|
|
|
171
190
|
while index < length
|
|
172
|
-
while index < length
|
|
191
|
+
while index < length
|
|
192
|
+
c = css.getbyte(index)
|
|
193
|
+
break unless c == 32 || c == 9 || c == 10 || c == 12 || c == 13
|
|
194
|
+
|
|
173
195
|
output << css[index]
|
|
174
196
|
index += 1
|
|
175
197
|
end
|
|
@@ -196,14 +218,14 @@ module StyleCapsule
|
|
|
196
218
|
next
|
|
197
219
|
end
|
|
198
220
|
|
|
199
|
-
|
|
200
|
-
at_name =
|
|
221
|
+
selectors_for_logic = selectors_raw.strip
|
|
222
|
+
at_name = selectors_for_logic[/\A@([a-zA-Z-]+)/, 1]&.downcase
|
|
201
223
|
entering_non_scoping = at_name && NON_SCOPING_AT_RULES.include?(at_name) && suppress_below_depth.nil?
|
|
202
224
|
|
|
203
225
|
skip_scoping = !suppress_below_depth.nil? ||
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
226
|
+
selectors_for_logic.start_with?("@") ||
|
|
227
|
+
selectors_for_logic.include?("[data-capsule=") ||
|
|
228
|
+
selectors_for_logic.empty?
|
|
207
229
|
|
|
208
230
|
if skip_scoping
|
|
209
231
|
output << selectors_raw
|
|
@@ -211,23 +233,41 @@ module StyleCapsule
|
|
|
211
233
|
suppress_below_depth = brace_depth
|
|
212
234
|
end
|
|
213
235
|
else
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
236
|
+
selectors_stripped = selectors_for_logic
|
|
237
|
+
leading = selectors_raw[/\A\s*/]
|
|
238
|
+
trailing = selectors_raw[/\s*\z/]
|
|
217
239
|
|
|
218
|
-
|
|
219
|
-
|
|
240
|
+
if !selectors_stripped.include?(",")
|
|
241
|
+
sel = selectors_stripped
|
|
242
|
+
if sel.empty?
|
|
243
|
+
output << selectors_raw
|
|
244
|
+
elsif sel.start_with?(":host")
|
|
245
|
+
patched = sel
|
|
220
246
|
.gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
|
|
221
247
|
.gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
|
|
222
248
|
.gsub(/^:host\b/, capsule_attr)
|
|
249
|
+
output << "#{leading}#{patched}#{trailing}"
|
|
223
250
|
else
|
|
224
|
-
"#{
|
|
251
|
+
output << "#{leading}#{scoped_prefix}#{sel}#{trailing}"
|
|
225
252
|
end
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
253
|
+
else
|
|
254
|
+
piece_ary = selectors_stripped.split(",", -1)
|
|
255
|
+
scoped_selectors = piece_ary.map do |selector|
|
|
256
|
+
selector = selector.strip
|
|
257
|
+
next selector if selector.empty?
|
|
258
|
+
|
|
259
|
+
if selector.start_with?(":host")
|
|
260
|
+
selector
|
|
261
|
+
.gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
|
|
262
|
+
.gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
|
|
263
|
+
.gsub(/^:host\b/, capsule_attr)
|
|
264
|
+
else
|
|
265
|
+
scoped_prefix + selector
|
|
266
|
+
end
|
|
267
|
+
end.join(", ")
|
|
268
|
+
|
|
269
|
+
output << "#{leading}#{scoped_selectors}#{trailing}"
|
|
270
|
+
end
|
|
231
271
|
end
|
|
232
272
|
|
|
233
273
|
output << "{"
|
|
@@ -321,6 +361,8 @@ module StyleCapsule
|
|
|
321
361
|
return index if depth.zero?
|
|
322
362
|
|
|
323
363
|
next
|
|
364
|
+
else
|
|
365
|
+
# Non-brace characters stay inside the current block.
|
|
324
366
|
end
|
|
325
367
|
index += 1
|
|
326
368
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Storage for request-scoped stylesheet registry state and helper scope cache.
|
|
5
|
+
#
|
|
6
|
+
# Default +:thread+ keeps the historical backends:
|
|
7
|
+
# - +ActiveSupport::CurrentAttributes+ when available
|
|
8
|
+
# - +Thread.current+ otherwise (and for +HelperScopeCache+)
|
|
9
|
+
#
|
|
10
|
+
# Experimental +:fiber+ stores StyleCapsule keys on the current Fiber so concurrent
|
|
11
|
+
# Fibers on one Thread do not share request-scoped stylesheets or the helper cache.
|
|
12
|
+
# Does not change Rails' +ActiveSupport::IsolatedExecutionState.isolation_level+;
|
|
13
|
+
# Falcon-style apps should still set +config.active_support.isolation_level = :fiber+
|
|
14
|
+
# for the rest of Rails.
|
|
15
|
+
module ExecutionState
|
|
16
|
+
INLINE_KEY = :style_capsule_inline_stylesheets
|
|
17
|
+
REQUEST_FILES_KEY = :style_capsule_request_file_stylesheets
|
|
18
|
+
SCOPE_CACHE_KEY = :style_capsule_scope_cache
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def fiber_mode?
|
|
23
|
+
StyleCapsule.isolation_level == :fiber
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def [](key)
|
|
27
|
+
backend[key]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def []=(key, value)
|
|
31
|
+
backend[key] = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def delete(key)
|
|
35
|
+
store = backend
|
|
36
|
+
if store.respond_to?(:delete)
|
|
37
|
+
store.delete(key)
|
|
38
|
+
else
|
|
39
|
+
store[key] = nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def backend
|
|
44
|
+
if fiber_mode?
|
|
45
|
+
fiber_backend
|
|
46
|
+
else
|
|
47
|
+
thread_backend
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fiber_backend
|
|
52
|
+
state = Fiber.current.style_capsule_execution_state
|
|
53
|
+
unless state
|
|
54
|
+
state = {}
|
|
55
|
+
Fiber.current.style_capsule_execution_state = state
|
|
56
|
+
end
|
|
57
|
+
state
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def thread_backend
|
|
61
|
+
Thread.current
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless Fiber.method_defined?(:style_capsule_execution_state)
|
|
67
|
+
Fiber.attr_accessor :style_capsule_execution_state
|
|
68
|
+
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
require "strscan"
|
|
4
4
|
|
|
5
5
|
module StyleCapsule
|
|
6
|
-
# Bounded per-
|
|
6
|
+
# Bounded per-execution-scope cache for scoped CSS from ERB / standalone helpers
|
|
7
|
+
# (avoids unbounded Thread/Fiber store growth).
|
|
7
8
|
module HelperScopeCache
|
|
8
9
|
MAX_SCOPE_CACHE_ENTRIES = 256
|
|
9
10
|
STYLE_OPEN = %r{<style[^>]*>}im
|
|
@@ -49,7 +50,7 @@ module StyleCapsule
|
|
|
49
50
|
fingerprint = Digest::SHA1.hexdigest(css_content.to_s)
|
|
50
51
|
cache_key = "style_capsule_#{capsule_id}_#{fingerprint}"
|
|
51
52
|
|
|
52
|
-
bucket =
|
|
53
|
+
bucket = ExecutionState[ExecutionState::SCOPE_CACHE_KEY] ||= {order: [], hash: {}}
|
|
53
54
|
hash = bucket[:hash]
|
|
54
55
|
order = bucket[:order]
|
|
55
56
|
|
|
@@ -3,30 +3,15 @@
|
|
|
3
3
|
module StyleCapsule
|
|
4
4
|
# Centralized instrumentation module for StyleCapsule
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# zero performance impact when instrumentation is not being used.
|
|
6
|
+
# Hot paths skip ActiveSupport::Notifications subscriber checks and payload
|
|
7
|
+
# work; subscribing remains supported for manual testing or targeted profiling.
|
|
9
8
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# @example Subscribing to CSS processing events
|
|
9
|
+
# @example Subscribing to CSS processing events (manual profiling)
|
|
13
10
|
# ActiveSupport::Notifications.subscribe("style_capsule.css_processor.scope") do |name, start, finish, id, payload|
|
|
14
11
|
# duration_ms = (finish - start) * 1000
|
|
15
|
-
# # input_size is derived from :css_content when subscribers exist
|
|
16
12
|
# input_size = payload[:input_size]
|
|
17
13
|
# Rails.logger.info "CSS scoped in #{duration_ms}ms, input: #{input_size} bytes"
|
|
18
14
|
# end
|
|
19
|
-
#
|
|
20
|
-
# @example Using Event object for more details
|
|
21
|
-
# ActiveSupport::Notifications.subscribe("style_capsule.css_processor.scope") do |event|
|
|
22
|
-
# Rails.logger.info "CSS scoped in #{event.duration}ms, input: #{event.payload[:input_size]} bytes"
|
|
23
|
-
# end
|
|
24
|
-
#
|
|
25
|
-
# @example Subscribing to file write events
|
|
26
|
-
# ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.write") do |name, start, finish, id, payload|
|
|
27
|
-
# StatsD.timing("style_capsule.write.duration", (finish - start) * 1000)
|
|
28
|
-
# StatsD.histogram("style_capsule.write.size", payload[:size])
|
|
29
|
-
# end
|
|
30
15
|
module Instrumentation
|
|
31
16
|
# Check if ActiveSupport::Notifications is available
|
|
32
17
|
#
|
|
@@ -35,44 +20,17 @@ module StyleCapsule
|
|
|
35
20
|
!!defined?(ActiveSupport::Notifications)
|
|
36
21
|
end
|
|
37
22
|
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# - Only calculates sizes if subscribers exist (lazy evaluation)
|
|
43
|
-
# - Uses monotonic time for accurate measurements
|
|
44
|
-
#
|
|
45
|
-
# @param event_name [String] Event name following Rails convention (e.g., "style_capsule.css_processor.scope")
|
|
46
|
-
# @param payload [Hash] Additional payload data
|
|
47
|
-
# @yield The operation to instrument
|
|
48
|
-
# @yieldreturn [Object] Result of the operation (used for output size calculation if needed)
|
|
49
|
-
# @return [Object] Return value of the block
|
|
50
|
-
# @example Instrumenting CSS processing
|
|
51
|
-
# result = Instrumentation.instrument(
|
|
52
|
-
# "style_capsule.css_processor.scope",
|
|
53
|
-
# component_class: component_class.name,
|
|
54
|
-
# capsule_id: capsule_id,
|
|
55
|
-
# strategy: :selector_patching,
|
|
56
|
-
# css_content: css_content
|
|
57
|
-
# ) do
|
|
58
|
-
# CssProcessor.scope_selectors(css_content, capsule_id)
|
|
59
|
-
# end
|
|
60
|
-
# # When subscribers exist, :input_size is set from :css_content before the block runs.
|
|
23
|
+
# @param event_name [String] Event name
|
|
24
|
+
# @param payload [Hash] Payload data
|
|
25
|
+
# @yield The operation to run
|
|
26
|
+
# @return [Object] Block return value
|
|
61
27
|
def self.instrument(event_name, payload = {}, &block)
|
|
62
28
|
return yield unless available?
|
|
63
29
|
|
|
64
|
-
|
|
65
|
-
# If no subscribers, just execute the block without any overhead
|
|
66
|
-
unless ActiveSupport::Notifications.notifier.listening?(event_name)
|
|
67
|
-
return yield
|
|
68
|
-
end
|
|
30
|
+
return yield unless ActiveSupport::Notifications.notifier.listening?(event_name)
|
|
69
31
|
|
|
70
32
|
apply_input_size!(payload)
|
|
71
33
|
|
|
72
|
-
# Use ActiveSupport::Notifications.instrument which automatically:
|
|
73
|
-
# - Measures duration using monotonic time (available in event.duration)
|
|
74
|
-
# - Only does work if subscribers exist (zero overhead if no subscribers)
|
|
75
|
-
# Note: output_size is not included in payload (subscribers can calculate from result if needed)
|
|
76
34
|
result = nil
|
|
77
35
|
ActiveSupport::Notifications.instrument(event_name, payload) do
|
|
78
36
|
result = yield
|
|
@@ -90,35 +48,21 @@ module StyleCapsule
|
|
|
90
48
|
end
|
|
91
49
|
private_class_method :apply_input_size!
|
|
92
50
|
|
|
93
|
-
# Instrument an event without a block (for events that don't have duration)
|
|
94
|
-
#
|
|
95
51
|
# @param event_name [String] Event name
|
|
96
52
|
# @param payload [Hash] Payload data
|
|
97
53
|
# @return [void]
|
|
98
|
-
# @example Instrumenting a fallback event
|
|
99
|
-
# Instrumentation.notify(
|
|
100
|
-
# "style_capsule.css_file_writer.fallback",
|
|
101
|
-
# component_class: component_class.name,
|
|
102
|
-
# original_path: original_path,
|
|
103
|
-
# fallback_path: fallback_path,
|
|
104
|
-
# exception: [e.class.name, e.message],
|
|
105
|
-
# exception_object: e
|
|
106
|
-
# )
|
|
107
54
|
def self.notify(event_name, payload = {})
|
|
108
55
|
return unless available?
|
|
109
56
|
|
|
110
|
-
# Only notify if subscribers exist
|
|
111
57
|
return unless ActiveSupport::Notifications.notifier.listening?(event_name)
|
|
112
58
|
|
|
113
59
|
ActiveSupport::Notifications.instrument(event_name, payload)
|
|
114
60
|
end
|
|
115
61
|
|
|
116
|
-
# Instrument CSS processing operations
|
|
117
|
-
#
|
|
118
62
|
# @param strategy [Symbol] Scoping strategy (:selector_patching or :nesting)
|
|
119
63
|
# @param component_class [Class, String] Component class
|
|
120
64
|
# @param capsule_id [String] Capsule ID
|
|
121
|
-
# @param css_content [String] CSS content
|
|
65
|
+
# @param css_content [String] CSS content
|
|
122
66
|
# @yield The CSS processing operation
|
|
123
67
|
# @return [String] Processed CSS
|
|
124
68
|
def self.instrument_css_processing(strategy:, component_class:, capsule_id:, css_content:, &block)
|
|
@@ -136,8 +80,6 @@ module StyleCapsule
|
|
|
136
80
|
end
|
|
137
81
|
end
|
|
138
82
|
|
|
139
|
-
# Instrument CSS file write operations
|
|
140
|
-
#
|
|
141
83
|
# @param component_class [Class] Component class
|
|
142
84
|
# @param capsule_id [String] Capsule ID
|
|
143
85
|
# @param file_path [String] File path
|
|
@@ -156,15 +98,6 @@ module StyleCapsule
|
|
|
156
98
|
end
|
|
157
99
|
end
|
|
158
100
|
|
|
159
|
-
# Instrument fallback to temporary directory
|
|
160
|
-
#
|
|
161
|
-
# @param component_class [Class] Component class
|
|
162
|
-
# @param capsule_id [String] Capsule ID
|
|
163
|
-
# @param original_path [String] Original file path that failed
|
|
164
|
-
# @param fallback_path [String] Fallback file path used
|
|
165
|
-
# @param exception [Array<String>] Exception [class_name, message]
|
|
166
|
-
# @param exception_object [Exception] The exception object
|
|
167
|
-
# @return [void]
|
|
168
101
|
def self.instrument_fallback(component_class:, capsule_id:, original_path:, fallback_path:, exception:, exception_object:)
|
|
169
102
|
notify(
|
|
170
103
|
"style_capsule.css_file_writer.fallback",
|
|
@@ -177,17 +110,6 @@ module StyleCapsule
|
|
|
177
110
|
)
|
|
178
111
|
end
|
|
179
112
|
|
|
180
|
-
# Instrument fallback failure (both original and fallback failed)
|
|
181
|
-
#
|
|
182
|
-
# @param component_class [Class] Component class
|
|
183
|
-
# @param capsule_id [String] Capsule ID
|
|
184
|
-
# @param original_path [String] Original file path that failed
|
|
185
|
-
# @param fallback_path [String] Fallback file path that also failed
|
|
186
|
-
# @param original_exception [Array<String>] Original exception [class_name, message]
|
|
187
|
-
# @param original_exception_object [Exception] Original exception object
|
|
188
|
-
# @param fallback_exception [Array<String>] Fallback exception [class_name, message]
|
|
189
|
-
# @param fallback_exception_object [Exception] Fallback exception object
|
|
190
|
-
# @return [void]
|
|
191
113
|
def self.instrument_fallback_failure(component_class:, capsule_id:, original_path:, fallback_path:, original_exception:, original_exception_object:, fallback_exception:, fallback_exception_object:)
|
|
192
114
|
notify(
|
|
193
115
|
"style_capsule.css_file_writer.fallback_failure",
|
|
@@ -202,14 +124,6 @@ module StyleCapsule
|
|
|
202
124
|
)
|
|
203
125
|
end
|
|
204
126
|
|
|
205
|
-
# Instrument write failure (non-permission errors)
|
|
206
|
-
#
|
|
207
|
-
# @param component_class [Class] Component class
|
|
208
|
-
# @param capsule_id [String] Capsule ID
|
|
209
|
-
# @param file_path [String] File path that failed
|
|
210
|
-
# @param exception [Array<String>] Exception [class_name, message]
|
|
211
|
-
# @param exception_object [Exception] The exception object
|
|
212
|
-
# @return [void]
|
|
213
127
|
def self.instrument_write_failure(component_class:, capsule_id:, file_path:, exception:, exception_object:)
|
|
214
128
|
notify(
|
|
215
129
|
"style_capsule.css_file_writer.write_failure",
|
|
@@ -221,8 +135,6 @@ module StyleCapsule
|
|
|
221
135
|
)
|
|
222
136
|
end
|
|
223
137
|
|
|
224
|
-
# Instrument stylesheet registration
|
|
225
|
-
#
|
|
226
138
|
# @param namespace [Symbol, String] Namespace
|
|
227
139
|
# @param file_path [String, nil] File path (if file-based)
|
|
228
140
|
# @param inline_size [Integer, nil] Inline CSS size in bytes (if inline)
|
|
@@ -230,6 +142,10 @@ module StyleCapsule
|
|
|
230
142
|
# @yield The registration operation
|
|
231
143
|
# @return [Object] Return value of the block
|
|
232
144
|
def self.instrument_registration(namespace:, file_path: nil, inline_size: nil, cache_strategy: :none, &block)
|
|
145
|
+
return yield unless available?
|
|
146
|
+
|
|
147
|
+
return yield unless ActiveSupport::Notifications.notifier.listening?("style_capsule.stylesheet_registry.register")
|
|
148
|
+
|
|
233
149
|
payload = {
|
|
234
150
|
namespace: namespace.to_s,
|
|
235
151
|
cache_strategy: cache_strategy
|
|
@@ -77,6 +77,10 @@ module StyleCapsule
|
|
|
77
77
|
MAX_INLINE_CACHE_ENTRIES = 256
|
|
78
78
|
FALLBACK_LINK_OPTION_NAME = /\A(?:media|title|crossorigin|integrity|nonce|type|id|class|data-[a-zA-Z0-9_-]+)\z/
|
|
79
79
|
|
|
80
|
+
# Frozen fragments for non-Rails inline <style> rendering.
|
|
81
|
+
INLINE_STYLE_OPEN = %(<style type="text/css">)
|
|
82
|
+
INLINE_STYLE_CLOSE = "</style>"
|
|
83
|
+
|
|
80
84
|
# Process-wide manifest for static file paths (like Propshaft)
|
|
81
85
|
# Organized by namespace: { namespace => { logical_path => { file_path:, options: } } }
|
|
82
86
|
@manifest = {} # rubocop:disable Style/ClassVars, ThreadSafety/MutableClassInstanceVariable
|
|
@@ -118,49 +122,57 @@ module StyleCapsule
|
|
|
118
122
|
defined?(ActiveSupport::CurrentAttributes) && self < ActiveSupport::CurrentAttributes
|
|
119
123
|
end
|
|
120
124
|
|
|
121
|
-
# Get inline stylesheets (thread-local
|
|
125
|
+
# Get inline stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
|
|
122
126
|
def inline_stylesheets
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
# CurrentAttributes automatically provides access to instance attributes
|
|
126
|
-
inst = instance
|
|
127
|
-
inst&.inline_stylesheets || {}
|
|
127
|
+
if use_execution_state_store?
|
|
128
|
+
ExecutionState[ExecutionState::INLINE_KEY] ||= {}
|
|
128
129
|
else
|
|
129
|
-
|
|
130
|
+
inst = instance
|
|
131
|
+
return {} unless inst
|
|
132
|
+
|
|
133
|
+
inst.inline_stylesheets ||= {}
|
|
130
134
|
end
|
|
131
135
|
end
|
|
132
136
|
|
|
133
|
-
# Set inline stylesheets (thread-local
|
|
137
|
+
# Set inline stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
|
|
134
138
|
def inline_stylesheets=(value)
|
|
135
|
-
if
|
|
136
|
-
|
|
139
|
+
if use_execution_state_store?
|
|
140
|
+
ExecutionState[ExecutionState::INLINE_KEY] = value
|
|
141
|
+
else
|
|
137
142
|
inst = instance
|
|
138
143
|
inst.inline_stylesheets = value if inst
|
|
139
|
-
else
|
|
140
|
-
Thread.current[:style_capsule_inline_stylesheets] = value
|
|
141
144
|
end
|
|
142
145
|
end
|
|
143
146
|
|
|
144
|
-
# Get request-scoped file stylesheets (thread-local
|
|
147
|
+
# Get request-scoped file stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
|
|
145
148
|
def request_file_stylesheets
|
|
146
|
-
if
|
|
147
|
-
|
|
148
|
-
inst&.request_file_stylesheets || {}
|
|
149
|
+
if use_execution_state_store?
|
|
150
|
+
ExecutionState[ExecutionState::REQUEST_FILES_KEY] ||= {}
|
|
149
151
|
else
|
|
150
|
-
|
|
152
|
+
inst = instance
|
|
153
|
+
return {} unless inst
|
|
154
|
+
|
|
155
|
+
inst.request_file_stylesheets ||= {}
|
|
151
156
|
end
|
|
152
157
|
end
|
|
153
158
|
|
|
154
|
-
# Set request-scoped file stylesheets (thread-local
|
|
159
|
+
# Set request-scoped file stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
|
|
155
160
|
def request_file_stylesheets=(value)
|
|
156
|
-
if
|
|
161
|
+
if use_execution_state_store?
|
|
162
|
+
ExecutionState[ExecutionState::REQUEST_FILES_KEY] = value
|
|
163
|
+
else
|
|
157
164
|
inst = instance
|
|
158
165
|
inst.request_file_stylesheets = value if inst
|
|
159
|
-
else
|
|
160
|
-
Thread.current[:style_capsule_request_file_stylesheets] = value
|
|
161
166
|
end
|
|
162
167
|
end
|
|
163
168
|
|
|
169
|
+
# Experimental Fiber mode always uses ExecutionState. Otherwise use thread/Fiber
|
|
170
|
+
# store when CurrentAttributes is unavailable.
|
|
171
|
+
def use_execution_state_store?
|
|
172
|
+
ExecutionState.fiber_mode? || !using_current_attributes?
|
|
173
|
+
end
|
|
174
|
+
private :use_execution_state_store?
|
|
175
|
+
|
|
164
176
|
# Get instance (for CurrentAttributes compatibility)
|
|
165
177
|
def instance
|
|
166
178
|
if using_current_attributes?
|
|
@@ -189,8 +201,12 @@ module StyleCapsule
|
|
|
189
201
|
# @param namespace [Symbol, String, nil] Namespace identifier
|
|
190
202
|
# @return [Symbol] Normalized namespace
|
|
191
203
|
def self.normalize_namespace(namespace)
|
|
192
|
-
return DEFAULT_NAMESPACE if namespace.nil?
|
|
193
|
-
|
|
204
|
+
return DEFAULT_NAMESPACE if namespace.nil?
|
|
205
|
+
|
|
206
|
+
s = namespace.to_s
|
|
207
|
+
return DEFAULT_NAMESPACE if s.strip.empty?
|
|
208
|
+
|
|
209
|
+
s.to_sym
|
|
194
210
|
end
|
|
195
211
|
|
|
196
212
|
# Register a stylesheet file path during rendering (request-scoped).
|
|
@@ -246,10 +262,7 @@ module StyleCapsule
|
|
|
246
262
|
inline_size: nil,
|
|
247
263
|
cache_strategy: :none
|
|
248
264
|
) do
|
|
249
|
-
|
|
250
|
-
registry[ns] ||= {}
|
|
251
|
-
registry[ns][path] = {file_path: path, options: options}
|
|
252
|
-
self.request_file_stylesheets = registry
|
|
265
|
+
(request_file_stylesheets[ns] ||= {})[path] = {file_path: path, options: options}
|
|
253
266
|
end
|
|
254
267
|
end
|
|
255
268
|
private_class_method :register_request_file
|
|
@@ -327,14 +340,11 @@ module StyleCapsule
|
|
|
327
340
|
inline_size: final_css.bytesize,
|
|
328
341
|
cache_strategy: cache_strategy
|
|
329
342
|
) do
|
|
330
|
-
|
|
331
|
-
registry[ns] ||= []
|
|
332
|
-
registry[ns] << {
|
|
343
|
+
(inline_stylesheets[ns] ||= []) << {
|
|
333
344
|
type: :inline,
|
|
334
345
|
css_content: final_css,
|
|
335
346
|
capsule_id: capsule_id
|
|
336
347
|
}
|
|
337
|
-
self.inline_stylesheets = registry
|
|
338
348
|
end
|
|
339
349
|
|
|
340
350
|
# Cache the CSS if strategy is enabled and not already cached
|
|
@@ -585,13 +595,18 @@ module StyleCapsule
|
|
|
585
595
|
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all namespaces)
|
|
586
596
|
# @return [String] HTML-safe string with stylesheet tags
|
|
587
597
|
def self.render_stylesheet_entries(stylesheets, view_context)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
598
|
+
buf = +""
|
|
599
|
+
stylesheets.each_with_index do |stylesheet, i|
|
|
600
|
+
buf << "\n" if i.positive?
|
|
601
|
+
piece =
|
|
602
|
+
if stylesheet[:type] == :inline
|
|
603
|
+
render_inline_stylesheet(stylesheet, view_context)
|
|
604
|
+
else
|
|
605
|
+
render_file_stylesheet(stylesheet, view_context)
|
|
606
|
+
end
|
|
607
|
+
buf << piece.to_s
|
|
608
|
+
end
|
|
609
|
+
safe_string(buf)
|
|
595
610
|
end
|
|
596
611
|
|
|
597
612
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- renders mixed inline and file registrations
|
|
@@ -602,7 +617,9 @@ module StyleCapsule
|
|
|
602
617
|
|
|
603
618
|
all_stylesheets = merged_file_registrations_all_namespaces
|
|
604
619
|
|
|
605
|
-
request_inline_stylesheets.
|
|
620
|
+
request_inline_stylesheets.each_value do |inline|
|
|
621
|
+
next if inline.empty?
|
|
622
|
+
|
|
606
623
|
all_stylesheets.concat(inline)
|
|
607
624
|
end
|
|
608
625
|
|
|
@@ -837,7 +854,7 @@ module StyleCapsule
|
|
|
837
854
|
def self.render_inline_stylesheet(stylesheet, _view_context)
|
|
838
855
|
css_content = stylesheet[:css_content]
|
|
839
856
|
CssProcessor.reject_style_element_breakout!(css_content)
|
|
840
|
-
safe_string(
|
|
857
|
+
safe_string(+(INLINE_STYLE_OPEN + css_content.to_s + INLINE_STYLE_CLOSE))
|
|
841
858
|
end
|
|
842
859
|
|
|
843
860
|
# Make string HTML-safe (compatible with Rails and non-Rails)
|
data/lib/style_capsule.rb
CHANGED
|
@@ -95,7 +95,25 @@ end
|
|
|
95
95
|
# # Files are automatically precompiled via: bin/rails assets:precompile
|
|
96
96
|
# # Or manually: bin/rails style_capsule:build
|
|
97
97
|
module StyleCapsule
|
|
98
|
+
@isolation_level = :thread
|
|
99
|
+
|
|
100
|
+
class << self
|
|
101
|
+
# Request-scoped storage isolation. Default +:thread+. Experimental +:fiber+
|
|
102
|
+
# isolates stylesheet registry request state and helper scope cache per Fiber.
|
|
103
|
+
attr_reader :isolation_level
|
|
104
|
+
|
|
105
|
+
def isolation_level=(level)
|
|
106
|
+
normalized = level.to_sym
|
|
107
|
+
unless %i[thread fiber].include?(normalized)
|
|
108
|
+
raise ArgumentError, "isolation_level must be :thread or :fiber, got: #{level.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@isolation_level = normalized
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
98
115
|
require_relative "style_capsule/version"
|
|
116
|
+
require_relative "style_capsule/execution_state"
|
|
99
117
|
require_relative "style_capsule/instrumentation"
|
|
100
118
|
require_relative "style_capsule/asset_path"
|
|
101
119
|
require_relative "style_capsule/helper_scope_cache"
|
data/sig/style_capsule.rbs
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
module StyleCapsule
|
|
2
2
|
VERSION: String
|
|
3
3
|
|
|
4
|
+
def self.isolation_level: () -> Symbol
|
|
5
|
+
def self.isolation_level=: (Symbol | String) -> Symbol
|
|
6
|
+
|
|
7
|
+
module ExecutionState
|
|
8
|
+
INLINE_KEY: Symbol
|
|
9
|
+
REQUEST_FILES_KEY: Symbol
|
|
10
|
+
SCOPE_CACHE_KEY: Symbol
|
|
11
|
+
|
|
12
|
+
def self.fiber_mode?: () -> bool
|
|
13
|
+
def self.[]: (Symbol key) -> untyped
|
|
14
|
+
def self.[]=: (Symbol key, untyped value) -> untyped
|
|
15
|
+
def self.delete: (Symbol key) -> untyped
|
|
16
|
+
end
|
|
17
|
+
|
|
4
18
|
module AssetPath
|
|
5
19
|
def self.validate_logical_path!: (String path) -> String
|
|
6
20
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: style_capsule
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
4
|
+
version: 2.0.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -69,14 +69,14 @@ dependencies:
|
|
|
69
69
|
requirements:
|
|
70
70
|
- - ">="
|
|
71
71
|
- !ruby/object:Gem::Version
|
|
72
|
-
version: 2.2.
|
|
72
|
+
version: 2.2.9
|
|
73
73
|
type: :development
|
|
74
74
|
prerelease: false
|
|
75
75
|
version_requirements: !ruby/object:Gem::Requirement
|
|
76
76
|
requirements:
|
|
77
77
|
- - ">="
|
|
78
78
|
- !ruby/object:Gem::Version
|
|
79
|
-
version: 2.2.
|
|
79
|
+
version: 2.2.9
|
|
80
80
|
- !ruby/object:Gem::Dependency
|
|
81
81
|
name: webmock
|
|
82
82
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -288,6 +288,7 @@ files:
|
|
|
288
288
|
- docs/changelogs/20260904180000_audit-fixes.md
|
|
289
289
|
- docs/changelogs/20260925094000_head-injection-bufferable-chunked.md
|
|
290
290
|
- docs/changelogs/20260928193000_sync-appraisal-locks-after-version-bump.md
|
|
291
|
+
- docs/changelogs/20261107115010_release-2-0-4.md
|
|
291
292
|
- docs/dependencies/20260904174500_json-cve-2026-71847.md
|
|
292
293
|
- docs/issues/20260904174500_engineering-and-dependency-audit.md
|
|
293
294
|
- docs/issues/20260917123253_ci-drop-automatic-dependency-audit.md
|
|
@@ -295,6 +296,9 @@ files:
|
|
|
295
296
|
- docs/non_rails_support.md
|
|
296
297
|
- docs/reviews/20261028_engineering-audit-release-preparation.md
|
|
297
298
|
- docs/reviews/20261103150931_engineering-prose-release-audit.md
|
|
299
|
+
- docs/reviews/20261106210000_pressure-optimizations-and-c-ext.md
|
|
300
|
+
- docs/reviews/20261107114500_fiber-isolation-experimental.md
|
|
301
|
+
- docs/reviews/20261107115318_prose-audit-markdown-cleanup.md
|
|
298
302
|
- lib/style_capsule.rb
|
|
299
303
|
- lib/style_capsule/asset_path.rb
|
|
300
304
|
- lib/style_capsule/class_registry.rb
|
|
@@ -304,6 +308,7 @@ files:
|
|
|
304
308
|
- lib/style_capsule/component_styles_support.rb
|
|
305
309
|
- lib/style_capsule/css_file_writer.rb
|
|
306
310
|
- lib/style_capsule/css_processor.rb
|
|
311
|
+
- lib/style_capsule/execution_state.rb
|
|
307
312
|
- lib/style_capsule/head_injection_middleware.rb
|
|
308
313
|
- lib/style_capsule/helper.rb
|
|
309
314
|
- lib/style_capsule/helper_scope_cache.rb
|