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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a6c3fda27b85ee3899cbe434dd5230de0d1c9c4ff6c961deefa9b058fd8f6b3
4
- data.tar.gz: e70f42c7ee1fc234201107862e1c7dfe9f609a9a55333983101aed4d1ec74ff7
3
+ metadata.gz: dac0417ef84666945aab821ea7e93da52c7ced67fde8cf402542606cffc47038
4
+ data.tar.gz: 4177a0af231cb8353dccb62cf468eb039f2a832e2cbf766755a4ed668f1efeaf
5
5
  SHA512:
6
- metadata.gz: 163cd5d8f4f0a324121a866adc94c48f31438cd560ba2b30b2d8f3721b645fe4a339e89308cb38552a96294fd8b01a834936615617ff6889203003f593c4e716
7
- data.tar.gz: 39ca2e8c56745ddcbeae553b401e3e1b0a4b92409f0d066f0a50a9309bc3efac5369f38e929e6d04f0df991b8abbbe30723602c7f5458ac152a4bdf67ad4dc3a
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 update Rails 7.2 dependencies
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
- [![Gem Version](https://badge.fury.io/rb/style_capsule.svg?v=2.0.3)](https://badge.fury.io/rb/style_capsule) [![Test Status](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/style_capsule.rb/graph/badge.svg?token=2U6NXJOVVM)](https://app.codecov.io/github/amkisko/style_capsule.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/style_capsule.svg?v=2.0.4)](https://badge.fury.io/rb/style_capsule) [![Test Status](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/style_capsule.rb/graph/badge.svg?token=2U6NXJOVVM)](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
- **With custom wrapper tag:**
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. **Selector Patching (default)**: Adds `[data-capsule="..."]` prefix to each selector
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. **CSS Nesting (optional)**: Wraps nestable rules in `[data-capsule="..."] { ... }`
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
- **Per-component (using `style_capsule` - recommended):**
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
- **With custom wrapper tag:**
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
- **Global (in base component class):**
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
- **Note:** If you change the strategy and it doesn't take effect, clear the CSS cache:
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
- **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.
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
- **In ERB:**
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
- **In Phlex (requires including `StyleCapsule::PhlexHelper`):**
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
- **In ViewComponent (requires including `StyleCapsule::ViewComponentHelper`):**
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
- **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.
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
- **Note:** `cache_strategy` accepts Symbol (`:time`), String (`"time"`), or Proc. Strings are automatically converted to symbols.
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. **Requires class method `def self.component_styles`**:
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
- **Configuration:**
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
- **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.
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
- **Precompilation:**
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
- **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.
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
- **Usage:**
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
- **Note**: The gem can be used without Rails! See [Non-Rails Support](#non-rails-support) below.
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
- 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)**.
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. **Scope ID Generation**: Each component class gets a unique scope ID based on its class name (shared across all instances)
472
- 2. **CSS Rewriting**: CSS selectors gain `[data-capsule="..."]` attribute selectors (or a nesting wrapper)
473
- 3. **HTML Wrapping**: Component content is wrapped in a scoped element
474
- 4. **Class names**: Class names in CSS and markup stay as written
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`
@@ -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
- patch_selectors(strip_comments(css_string.dup), capsule_attr)
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
- parts = []
109
- parts << hoisted unless hoisted.empty?
110
- parts << "#{capsule_attr} {\n#{nestable}\n}" unless nestable.strip.empty?
111
- parts.join("\n")
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?(%r{</style}i)
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 && css[index].match?(/\s/)
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
- selectors = selectors_raw.strip
200
- at_name = selectors[/\A@([a-zA-Z-]+)/, 1]&.downcase
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
- selectors.start_with?("@") ||
205
- selectors_raw.include?("[data-capsule=") ||
206
- selectors.empty?
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
- scoped_selectors = selectors.split(",").map do |selector|
215
- selector = selector.strip
216
- next selector if selector.empty?
236
+ selectors_stripped = selectors_for_logic
237
+ leading = selectors_raw[/\A\s*/]
238
+ trailing = selectors_raw[/\s*\z/]
217
239
 
218
- if selector.start_with?(":host")
219
- selector
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
- "#{capsule_attr} #{selector}"
251
+ output << "#{leading}#{scoped_prefix}#{sel}#{trailing}"
225
252
  end
226
- end.join(", ")
227
-
228
- leading = selectors_raw[/\A\s*/]
229
- trailing = selectors_raw[/\s*\z/]
230
- output << "#{leading}#{scoped_selectors}#{trailing}"
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-thread cache for scoped CSS from ERB / standalone helpers (avoids unbounded Thread.current growth).
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 = Thread.current[:style_capsule_scope_cache] ||= {order: [], hash: {}}
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
- # Provides efficient, non-blocking instrumentation using ActiveSupport::Notifications.
7
- # Metrics (time, size) are only calculated when subscribers are present, ensuring
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
- # All events follow Rails naming convention: style_capsule.{module}.{event}
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
- # Instrument an operation with automatic timing and size metrics
39
- #
40
- # This method is highly efficient:
41
- # - Only measures time if subscribers exist (ActiveSupport::Notifications is optimized for this)
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
- # Check if there are any subscribers (ActiveSupport optimizes this check)
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 (`:input_size` derived lazily when subscribed)
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 fallback if not using CurrentAttributes)
125
+ # Get inline stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
122
126
  def inline_stylesheets
123
- if using_current_attributes?
124
- # When using CurrentAttributes, access the instance attribute
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
- Thread.current[:style_capsule_inline_stylesheets] ||= {}
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 fallback if not using CurrentAttributes)
137
+ # Set inline stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
134
138
  def inline_stylesheets=(value)
135
- if using_current_attributes?
136
- # When using CurrentAttributes, set via the instance
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 fallback if not using CurrentAttributes)
147
+ # Get request-scoped file stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
145
148
  def request_file_stylesheets
146
- if using_current_attributes?
147
- inst = instance
148
- inst&.request_file_stylesheets || {}
149
+ if use_execution_state_store?
150
+ ExecutionState[ExecutionState::REQUEST_FILES_KEY] ||= {}
149
151
  else
150
- Thread.current[:style_capsule_request_file_stylesheets] ||= {}
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 fallback if not using CurrentAttributes)
159
+ # Set request-scoped file stylesheets (CurrentAttributes, thread-local, or experimental Fiber store)
155
160
  def request_file_stylesheets=(value)
156
- if using_current_attributes?
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? || namespace.to_s.strip.empty?
193
- namespace.to_sym
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
- registry = request_file_stylesheets
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
- registry = inline_stylesheets
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
- stylesheets.map do |stylesheet|
589
- if stylesheet[:type] == :inline
590
- render_inline_stylesheet(stylesheet, view_context)
591
- else
592
- render_file_stylesheet(stylesheet, view_context)
593
- end
594
- end.join("\n").then { |s| safe_string(s) }
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.each do |_ns, inline|
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(%(<style type="text/css">#{css_content}</style>))
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StyleCapsule
4
- VERSION = "2.0.3"
4
+ VERSION = "2.0.4"
5
5
  end
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"
@@ -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.3
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.0
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.0
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