Skip to content

Give each Ractor its own attribute cache so components render on non-main Ractors - #1055

Open
mhenrixon wants to merge 8 commits into
yippee-fun:mainfrom
mhenrixon:fix/ractor-local-attribute-cache
Open

mhenrixon wants to merge 8 commits into
yippee-fun:mainfrom
mhenrixon:fix/ractor-local-attribute-cache

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rendering a component inside a non-main Ractor fails on every Phlex release:

can not access non-shareable objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractor (Ractor::IsolationError)

A non-main Ractor can't read an unshareable object through a constant or a module instance variable, and the render path had several. This PR removes each one, without changing anything for code that only ever renders on the main Ractor.

  • Attribute cache. Phlex.attribute_cache returns ATTRIBUTE_CACHE on the main Ractor and a Ractor-local FIFO anywhere else (Ractor.store_if_absent on Ruby 3.4+, ractor-local storage before that). It is resolved once per render into Phlex::SGML::State, so element methods read an ivar and nothing per element touches Ractor storage. A Ractor-local cache is sized like the main one and catches up when later templates expand it; FIFO#grow_to does that under the FIFO's lock, so racing threads in one Ractor grow it once.
  • tag(:name, …). tag reads the built-in modules' element registries. Each built-in module now ends its body with __freeze_registries__, which makes both registries shareable in place (no copy). A registration on an already-frozen registry swaps in a frozen copy instead of mutating it. Component classes' own registries (register_element in a component) are never frozen.
  • Compiler. With Phlex::Compiler.enable!, only the main Ractor compiles; other Ractors skip the compiler lock (which they can't take) and render whatever is defined, compiled or not. The main Ractor's path is unchanged, so a render there still waits for a recompilation in progress.
  • Compiled source maps. cache (via Phlex.__source_line__) and the exception mapping in compiled code now read a frozen, shareable copy of COMPILED_SOURCE_MAPS that the compiler republishes after each write. Without it, cache failed on a worker and an error raised by a compiled component surfaced as Ractor::IsolationError.
  • Rubies without Ractors (TruffleRuby, JRuby) always use ATTRIBUTE_CACHE.

Verified on Ruby 4.0.6: standard, void, custom (register_element), SVG and tag(…) elements, cache, and compiled components all render on worker Ractors byte-identical to the main Ractor.

Environment notes

  • Text goes through ERB::Escape.html_escape, which is only marked Ractor-safe since erb 5.0.2 (Ruby 4.0 bundles one; 3.3/3.4's bundled erb is not). The render tests probe for it and only run when it holds.
  • Before Ruby 4.0 a non-main Ractor cannot autoload in general, so on 3.2/3.3 call Phlex::Loader.eager_load first. On 4.0 a worker-triggered autoload runs on the main Ractor (verified for Phlex::SVG).
  • The head lanes need refract with Prism's ErrorRecoveryNode support (fix: convert Prism::ErrorRecoveryNode, which replaced MissingNode refract#4, merged); they pass on this branch since merging main.

Benchmarks (bench.rb verbatim, Ruby 3.4.2 +YJIT, same machine)

Against main:

this PR main
Page (before compile) 3.477k i/s 3.516k i/s
Dynamic list (before compile) 15.795k i/s 15.632k i/s
Page (after compile) 91.683k i/s 93.344k i/s
Dynamic list (after compile) 34.874k i/s 34.687k i/s

The tag() commit against the commit before it, three alternating runs each, uncompiled Page: 3.53k / 3.00k / 3.21k (with) vs 1.71k / 2.97k / 3.34k (without); 22–25 major GCs both ways. Within noise; the bench fixtures don't call tag.

A note for anyone benchmarking this area: the uncompiled run is sensitive to heap layout. An earlier version of the tag() fix that replaced the registry with a new frozen Hash on every registration did slow bench.rb ~2×, which is why the registries are frozen in place once instead. Custom harnesses that GC.start before measuring flip into a nofree major-GC loop on unrelated changes, so compare with bench.rb and its major-GC count.

Test plan

  • bundle exec qt in a clean checkout of main + this branch: Ruby 3.4.2 — 3571 passed, 4 failed, 0 errors; Ruby 4.0.6 — 3577 passed, 4 failed, 0 errors. The 4 failures are the same on an untouched main.
  • bundle exec rubocop — no offenses
  • quickdraw/ractor.test.rb: per-Ractor cache identity, size and growth; rendering, tag, cache, compiled components and their errors on a worker Ractor; the built-in registries are shareable
  • quickdraw/sgml/element_registry.test.rb: registries frozen after load; registering after the freeze (standard, void, void → standard, Symbol and caller-owned String tags) keeps them frozen and shareable without mutating earlier snapshots; component registries stay mutable
  • quickdraw/fifo.test.rb: grow_to semantics and racing threads
  • Each new test fails with its fix reverted
  • Load + tag + late registration on Ruby 3.2.8 and 3.3.8; a simulated Ractor-less Ruby (remove_const(:Ractor))

Conformance case

quickdraw/ractor.test.rb:113: "renders inside a non-main Ractor with the same output"

bundle exec qt -p 1 ./quickdraw/ractor.test.rb
  • Without the fix (fdfb78e), Ruby 4.0.7: fails with Ractor::IsolationError: can not access non-shareable objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractor from div. The registry test fails on @__registered_elements__ from Phlex::HTML::StandardElements. (On main, running the whole file hangs at the cache-growth test, whose Ractor raises before it replies, so run the render test alone there.)
  • With this PR: the file passes on Ruby 4.0.7.

The Example component uses attributes, data:, a void element, tag, a registered custom element and SVG, so a new shared cache or registry on any of those paths fails it; the Ractor tests are defined only on Rubies with Ractor.store_if_absent, and the render tests only where ERB::Escape is Ractor-safe.

…main Ractors

Rendering inside a non-main Ractor raised `can not access non-shareable
objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractor`: the cache is
a FIFO (a Hash behind a Monitor), which a non-main Ractor can never read
through a constant.

`Phlex.attribute_cache` now returns ATTRIBUTE_CACHE on the main Ractor and a
Ractor-local FIFO anywhere else (`Ractor.store_if_absent` on Ruby 3.4+,
ractor-local storage before that), sized like the main one including the
expansion for every view_template seen so far. It is resolved once per render
into Phlex::SGML::State, so element methods pay an ivar read instead of a
constant lookup and nothing per element touches Ractor storage.

Not covered yet: `tag(:name, ...)` reads the element registries, which are
mutable Hashes in module instance variables and so still unreadable from a
non-main Ractor. Making them copy-on-write costs one transient Hash per
registration at boot, which is enough to shift the 160-byte size pool across
a page boundary before the first GC and park bench.rb's uncompiled run in a
`nofree` major-GC loop (2x slower, 2 vs ~380 majors); that wants an explicit
freeze step instead, in a follow-up.

Text escaping goes through ERB::Escape, whose html_escape is only marked
Ractor-safe since erb 5.0.2 (Ruby 4.0 ships one); the render test is gated
on that. Before Ruby 4.0 a non-main Ractor cannot autoload, so on 3.2/3.3
eager-load Phlex first.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T15:59:39.414018Z 3dabb93 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82c1665b5a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/phlex.rb Resolved
Comment thread lib/phlex.rb Resolved
lib/phlex.rb called Ractor.respond_to? at load time, so on TruffleRuby,
which has no Ractor constant, requiring Phlex raised NameError. Without
Ractors there is only ever one cache, so attribute_cache returns
ATTRIBUTE_CACHE; the Ractor tests are skipped there.
@mhenrixon

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the TruffleRuby lanes: lib/phlex.rb touched Ractor at load time, and TruffleRuby has no Ractor constant. Without Ractors, Phlex.attribute_cache now just returns ATTRIBUTE_CACHE, and the Ractor tests skip.

The head lanes fail for an unrelated reason: uninitialized constant Prism::MissingNode inside refract (lib/refract/converter.rb:845). ruby/ruby@708656c73 replaced MissingNode with ErrorRecoveryNode, and refract's main (51015bbe, the commit this lockfile pins) still references it. The last green head run on main is from Oct 5, before that reached ruby-head, so main should fail the same way on a rerun.

__expand_attribute_cache__ only grows ATTRIBUTE_CACHE and the expansion
counter, so a Ractor whose cache already existed kept the size it was
created with: in a lazily loaded app whose later templates pass the 1 MiB
floor, worker Ractors would evict and regenerate attributes the main one
keeps. A Ractor-local cache now catches up when it is next fetched, which
is once per render; the main Ractor's path is unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e803f75e28

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/phlex/sgml/state.rb Resolved
With Phlex::Compiler.enable!, every render went through
compile_on_first_render, which takes Compiler::MUTEX before checking
whether the component is already compiled. A non-main Ractor can't read a
Mutex through a constant, so compiler-enabled apps couldn't render there at
all. A compiled component now skips the lock (the flag is only set once its
compilation has finished), and only the main Ractor compiles: workers render
whatever is defined, compiled or not.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4289d5908a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/phlex.rb Resolved
Comment thread lib/phlex.rb Resolved
SGML#cache (through Phlex.__source_line__) and the exception mapping in
compiled code read COMPILED_SOURCE_MAPS, a mutable Hash in a constant that
a non-main Ractor can't touch: a cached fragment couldn't render there, and
an error raised by a compiled component came out as Ractor::IsolationError.
The compiler, which only runs on the main Ractor under its lock, now also
publishes a frozen, shareable copy after each write, and both readers use it.

A Ractor-local attribute cache was topped up with compare-then-expand, so
threads in one worker racing past a stale size could each add the shortfall.
FIFO#grow_to raises the limit to a target under the FIFO's lock instead.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63ea2e0ee8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/phlex.rb Resolved
@mhenrixon

Copy link
Copy Markdown
Contributor Author

The two head lanes are fixed upstream in refract: yippee-fun/refract#4. It converts Prism::ErrorRecoveryNode, which replaced MissingNode in ruby/ruby@708656c73. Run against prism's main (ErrorRecoveryNode, no MissingNode), refract's main fails to load with the same NameError these lanes show, and the PR branch passes. Phlex resolves refract main fresh, so once that merges a re-run should turn the lanes green with no change here.

…stries

tag looks names up in the built-in modules' element registries, mutable
Hashes in module instance variables that a non-main Ractor can't read.
Each built-in module now ends its body with __freeze_registries__, which
makes both registries shareable in place: no copy, and nothing changes for
the registrations before it. A registration on a registry that is already
frozen swaps in a frozen copy instead of mutating it, and stores a frozen
copy of the tag so the caller's String is never frozen. Component classes'
own registries are never frozen.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3749a622b4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/phlex/compiler.rb Resolved
4289d59 let any compiled component skip Compiler::MUTEX, on the main Ractor
too. A recompilation (an element or helper override) holds that lock but
leaves the compiled flag set, so a render starting on another main-Ractor
thread meanwhile ran the old inlined method instead of waiting. Only worker
Ractors, which can't take the lock or compile, skip it now; the main
Ractor's path is the one it had before this PR.
@mhenrixon

Copy link
Copy Markdown
Contributor Author

I think I'm done now @joeldrapper, would love to see these tweaks in Phlex so that I can make my stack ractor-friendly.

mhenrixon added a commit to zoolutions/phlex-forms that referenced this pull request Oct 15, 2026
conformance-kit's "runs inside a non-main Ractor" (tagged :ractor, Ruby
4.0; skipped with the kit's reason below) runs, with the configuration
finalized: Forms::Field option resolution and PhlexForms::Inference on a
literal Struct model, theme resolution and the icon renderer, and the
form_options / live class settings of constant form classes. Before the
two previous fixes the class-settings examples raised
Ractor::IsolationError on @form_modifiers and @live_scope.

Rendering is a pending example with its reason: Phlex reads its
non-shareable Phlex::ATTRIBUTE_CACHE on every element with attributes
(yippee-fun/phlex#1055). It fails today; once upstream lands the pending
example passes and RSpec reports it, so it cannot go stale silently.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/phlex-forms that referenced this pull request Oct 15, 2026
audit.require points at spec/conformance_eager_load.rb, which
eager-loads both Zeitwerk roots (PhlexForms, Forms) and finalizes the
configuration as a host's boot does; audit.strict_autoloads makes the
strict audit fail on any autoload left unloaded. No main_only and no
skip_autoloads entries: nothing is left that needs one. The Ractor gate
runs the :ractor examples; probes.gem_name enables the report-only
compiler probes.

`bundle exec conformance-kit check` on Ruby 4.0.6: doctor ok, 6 :ractor
examples (0 failures, 1 pending: rendering, yippee-fun/phlex#1055), and
conformance-audit: 0 findings, 70 modules audited, 0 unloaded autoloads.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/daisyui that referenced this pull request Oct 15, 2026
Only the main Ractor can eager-load (Zeitwerk's registry is main-only), so
DaisyGroups skips it elsewhere: finalize! already loaded everything. The
:ractor canary merges inside Ractor.new on Ruby 4.0. Rendering stays
pending on Phlex (Phlex::ATTRIBUTE_CACHE, yippee-fun/phlex#1055); DaisyUI's
own render-time code gets that far.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/docs-kit that referenced this pull request Oct 15, 2026
…ctor

conformance-kit's "runs inside a non-main Ractor" canary (Ruby 4.0;
skipped with a reason below) now builds a SearchIndex from literal pages,
assembles llms.txt and llms-full.txt from a Configuration built inside
the Ractor, and builds a literal registry's NavItem tree. Two blockers
were docs-kit's own: Configuration::DEFAULT_NAV was an unshareable Proc
(every Configuration.new read it) and a registry's page list was an
unfrozen Array in a class ivar. The page DSL now makes every value
shareable on write, DEFAULT_NAV is a shareable lambda, and
DocsUI::Callout::LEVELS is frozen all the way down.

Rendering cannot run in a Ractor yet: the Phlex, commonmarker and
Nokogiri canaries are pending with the upstream reason
(yippee-fun/phlex#1055, commonmarker's Proc-defined options, nokogiri's
C extension), and turn red the day one of them passes.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/phlex-reactive that referenced this pull request Oct 15, 2026
conformance-kit's "runs inside a non-main Ractor" example, tagged :ractor
and skipped below Ruby 4.0 with the kit's reason, over what a non-main
Ractor can run once the configuration is finalized: param coercion through
a compiled ParamSchema, Phlex::Reactive::Stream wrapping, Streamable#dom_id,
reading the finalized configuration, and token-payload upgrades.

ParamSchema::BOOLEAN_TYPE is now Ractor-shareable: the canary found the
:boolean caster unreadable from a non-main Ractor.

Two canaries are pending, each with its upstream reason, and fail loudly
the day they pass: signing/verifying (an ActiveSupport::MessageVerifier
cannot be built or used outside the main Ractor) and rendering (Phlex 2.4.1
reads its unshareable ATTRIBUTE_CACHE on every element with attributes,
yippee-fun/phlex#1055).

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/phlex-reactive that referenced this pull request Oct 15, 2026
A ractor_readiness check, the same shape as the fiber isolation one:
eager loading on, the configuration finalized, and every setting a non-main
Ractor can read (Doctor.unshareable_settings names each one that holds an
unshareable value, with its class). Advisory only, never a failure: no app
has to run Ractors, and the ok line names what still blocks Ractor mode
upstream (Rails, ActiveSupport's MessageVerifier, Phlex rendering,
yippee-fun/phlex#1055). It never loads a server.

Phlex::Reactive::CONFIG_READERS is the one list of configuration readers,
shared by the doctor and the finalize spec.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/phlex-reactive that referenced this pull request Oct 15, 2026
The tiers phlex-reactive is held to and their gates, why request state is
fiber-local (and what the per-fiber view-context rebuild costs a Falcon
request, measured), the configuration finalized at boot and what it changes
for an app, what Ractor mode needs (eager loading) and what waits upstream
(Phlex rendering, yippee-fun/phlex#1055; ActiveSupport's MessageVerifier;
Rails), and the doctor's Ractor readiness line.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/phlex-forms that referenced this pull request Oct 15, 2026
* chore(ci): call conformance-kit's conformance workflow at v0.3.0

Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot
keep both pins current. The conformance check is not required yet: it
turns green, and becomes required, with #42.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): group the two conformance-kit pins into one Dependabot update

A tag bump without the gem (or the reverse) fails the conformance version
check, so both now arrive in a single multi-ecosystem pull request.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): inherit conformance-kit's RuboCop config

Adds rubocop-thread_safety (>= 0.8, what conformance-kit doctor checks)
and inherits conformance-kit's config/rubocop.yml, so the Tier 1
ThreadSafety cops run in the Lint job. The offences it reports in lib/
are fixed in the commits that follow, not baselined.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(theme): build the themes without phlex-reactive

Theme.daisy / Theme.plain named Forms::TagField and its rootless twin as
arguments to reactive_roles, so the classes were resolved before the
defined?(Phlex::Reactive) guard ran. Without phlex-reactive those files
are ignored by the loader, and the first theme lookup raised NameError:
the soft dependency was in fact hard. reactive_roles now takes the
namespace and names the classes only behind the guard.

The spec boots the gem in a child process with phlex-reactive off the
load path.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(core): finalize the configuration and themes at boot

The configuration and both built-in themes lived in lazy class ivars
(`@configuration ||=`, `@daisy ||=`, `@plain ||=`, plus lazy defaults in
Configuration's readers): check-then-set races, and unreadable from a
non-main Ractor.

- Theme.daisy / Theme.plain are the DAISY / PLAIN constants, built once
  when theme.rb loads; a Theme is frozen on construction, so a theme of
  component classes is Ractor-shareable.
- Configuration sets every default in initialize; no reader memoizes.
  The default icon renderer is a capture-free lambda made shareable in
  the class body, and glyphs_renderer returns a shareable one too.
- PhlexForms.finalize! stores Ractor-shareable copies of every setting
  (PhlexForms::Shareable.copy, which never freezes the caller's objects)
  and freezes the configuration. configure calls it when the Rails
  engine is absent; the engine calls it after_initialize. configure on a
  finalized configuration edits a copy and swaps it in whole.
- A renderer that cannot be shared (a top-level lambda in an
  initializer) is kept as given and still works on the main Ractor.
- The configuration is built when the gem loads, never lazily; one
  inline ThreadSafety/ClassInstanceVariable disable covers the module's
  configuration accessors, with the reason beside it.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(theme): theme resolution under thread and fiber contention

conformance-kit's "thread-safe under contention" and "fiber-safe under
an Async reactor" examples over Theme.resolve, the lookup every field
makes, require one Theme object across 8 threads/fibers x 50 calls. On
the lazy `@daisy ||=` themes the thread example saw 8 distinct Theme
objects in 2 of 3 runs; on the load-time constants it cannot race.

Adds async (development only, require: false) so the fiber example runs
instead of skipping.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(builder): form_options stores Ractor-shareable copies

form_options kept its arguments as-is in the form class's ivars, so a
non-main Ractor reading form_modifiers / form_defaults raised
Ractor::IsolationError. It now stores PhlexForms::Shareable.copy of
both (deep-frozen copies; the caller's objects stay unfrozen, and a
value that cannot be shared, such as a lambda with an unshareable self,
is kept as given for the main Ractor). The readers already build new
values per call, so nothing else changes.

The ThreadSafety/ClassInstanceVariable disable around the macro and its
two readers carries the reason: written while the class body loads, at
boot on the main Ractor, and shareable once written.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(validation): the live macros store Ractor-shareable settings

live (setup_live), live_permit, live_deny and live_tags stored mutable
strings, arrays and a hash in the form class's ivars, unreadable from a
non-main Ractor. They now store PhlexForms::Shareable.copy of each; the
model class and the debounce were shareable already.

conformance-audit also flagged Forms::Live.@_dependencies, the Array
ActiveSupport::Concern keeps of the concerns Forms::Live includes. It is
filled while the module body loads and only read after (when a form
class includes Forms::Live), so it is frozen at the end of the body.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* refactor(components): render <lexxy-editor> through tag()

register_element :lexxy_editor left Forms::RichTextarea with a mutable
per-class element registry (@__registered_elements__, Phlex's own),
which conformance-audit reports as unshareable class state. Phlex's
tag(:lexxy_editor, ...) (Phlex >= 2.0.0, the gemspec floor) renders the
same custom element, attributes and block, with no registry on the
class. The rich-textarea specs are unchanged and green.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(core): Ractor canary for field inference and form settings

conformance-kit's "runs inside a non-main Ractor" (tagged :ractor, Ruby
4.0; skipped with the kit's reason below) runs, with the configuration
finalized: Forms::Field option resolution and PhlexForms::Inference on a
literal Struct model, theme resolution and the icon renderer, and the
form_options / live class settings of constant form classes. Before the
two previous fixes the class-settings examples raised
Ractor::IsolationError on @form_modifiers and @live_scope.

Rendering is a pending example with its reason: Phlex reads its
non-shareable Phlex::ATTRIBUTE_CACHE on every element with attributes
(yippee-fun/phlex#1055). It fails today; once upstream lands the pending
example passes and RSpec reports it, so it cannot go stale silently.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): .conformance.yml with a strict, eager-loading audit

audit.require points at spec/conformance_eager_load.rb, which
eager-loads both Zeitwerk roots (PhlexForms, Forms) and finalizes the
configuration as a host's boot does; audit.strict_autoloads makes the
strict audit fail on any autoload left unloaded. No main_only and no
skip_autoloads entries: nothing is left that needs one. The Ractor gate
runs the :ractor examples; probes.gem_name enables the report-only
compiler probes.

`bundle exec conformance-kit check` on Ruby 4.0.6: doctor ok, 6 :ractor
examples (0 failures, 1 pending: rendering, yippee-fun/phlex#1055), and
conformance-audit: 0 findings, 70 modules audited, 0 unloaded autoloads.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: finalize-at-boot configuration in the README and CHANGELOG

README: a "Configuration is finalized at boot" section (when it
finalizes, writing through configure afterwards, renderers that cannot
be shared). CHANGELOG [Unreleased]: PhlexForms.finalize! and the
shareable themes and class macros (Added), the frozen configuration and
the renderer rule (Changed), and the theme fix without phlex-reactive
(Fixed).

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): call conformance-kit's conformance workflow at v0.4.0

Moves both pins together: the caller's tag and the Gemfile's ~> 0.4, so the
version handshake matches. No tracked lockfile pins the kit (the root
Gemfile.lock is ignored, docs/ does not bundle it).

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(core): the engine finalizes the configuration at Rails boot

Boots a minimal Rails app in a child process (so the engine never loads into
the suite) and checks the configuration is frozen and shareable after
after_initialize with no app code, that initializer writes (configure and a
direct write) are kept, and that an icon renderer which cannot be shared (a
top-level lambda closing over a Logger) boots and still renders. Seen failing
with the engine's after_initialize removed.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(core): finalizing keeps a non-Proc icon renderer as given

Shareable.copy deep-copies and freezes any object it can, so an icon renderer
object with state of its own (a cache) was swapped for a frozen copy that
raised FrozenError on its first render. A renderer that is not a Proc is now
kept as given: shared only if it already is.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore: bundle exec rake passes from the root

The root RuboCop walked docs/, whose .rubocop.yml requires docs_kit/rubocop
from the docs app's own bundle, so `rake` (spec + rubocop) aborted on main.
docs/ is excluded from the root lint, and the one remaining offence
(Bundler/OrderedGems in the Gemfile) is fixed by moving phlex-reactive into
order.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: the 0.5.0 changelog and how finalizing hooks in

Moves this PR's entries out of the [Unreleased] pile into a [0.5.0] section
with an "Upgrading to 0.5" list (auto-finalize, FrozenError on a direct write
after boot, unshareable values kept as given), and heads the released pile
"[0.4.1] and earlier". The README says when finalizing happens with and
without Rails, and that non-Proc renderers are never copied or frozen.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(core): configure finalizes outside Rails with phlex-reactive bundled

Finalizing builds the themes, which load the tag-field leaves and with them
phlex-reactive's Sortable, which calls Hash#to_json while it loads without
requiring json. A Rails host has json loaded; a plain Ruby host got
NoMethodError from PhlexForms.configure (before this PR, from its first
form). phlex-forms now requires json before phlex-reactive, and the
eager-load file's workaround goes.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(core): configure returns its block's value again

Before finalizing, configure was `yield(configuration)` and returned what the
block returned; the finalizing versions returned the configuration or nil.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(core): field_variants set to nil stays an appendable list

The reader stopped memoizing `@field_variants ||= []`, so after
`c.field_variants = nil` a later `c.field_variants << :sm` appended to a
throwaway array. The writer now stores nil as an empty list.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: Ractors read the configuration when every setting is shareable

The changelog and README promised a Ractor-shareable configuration even when
an unshareable value (a top-level renderer lambda) is kept as given. Also adds
the json fix to the 0.5.0 Fixed list.

Refs #42

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): label conformance-kit Dependabot PRs dependencies + devops

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/docs-kit that referenced this pull request Oct 15, 2026
…-finalize, Ractor canary, strict audit) (#103)

* chore(ci): call conformance-kit's conformance workflow at v0.3.0

Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot
keep both pins current. The conformance check is not required yet: it
turns green, and becomes required, with #91.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): group the two conformance-kit pins into one Dependabot update

A tag bump without the gem (or the reverse) fails the conformance version
check, so both now arrive in a single multi-ecosystem pull request.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(registry): build a cold snapshot once, per Ractor, under a lock

DocsKit::Snapshot.for kept its cache in an unguarded class ivar
(`@cache ||= {}`): threads that missed a cold key together each read and
parsed the manifest and raced to store it (32 threads parsed it up to 26
times). The cache now lives in DocsKit::Store, a copy of conformance-kit's
per-Ractor store (the kit is a development dependency only), and its
check-then-set runs under that Ractor's lock. A Snapshot builds its
registries in initialize, so a cached instance carries no lazy memo.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(engine): finalize the configuration at boot; lock the OpenAPI memo

Every DocsUI::Code render resolved its Rouge theme with Object.const_get,
every nav render constantized each page's view class, and the brand logo,
SEO, landing and optional-mcp memos were filled by whichever request got
there first. DocsKit.finalize! resolves the themes and every registered
page's view class once into frozen (Ractor-shareable) maps and fills those
memos; the engine runs it in after_initialize, after the host's
to_prepare configure, unless code reloading is on. DocsKit.configure
re-opens it, and a theme assigned directly afterwards still wins.

Configuration#openapi_document kept its mtime memo in unguarded ivars:
threads that missed together each parsed the spec. The memo now lives in
a per-Ractor DocsKit::Store cache whose check-then-set runs under a lock.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(registry): hold Scope.with and the snapshot sidebar to threads and fibers

Puma serves docs-kit from threads and Falcon from fibers in one reactor.
conformance-kit's "thread-safe under contention" and "fiber-safe under an
Async reactor" examples now run DocsKit::Scope.with (each request keeps its
own version and leaves none behind) and an archived-version sidebar
(DocsKit::Snapshot.for + Configuration#nav_groups: one snapshot, one
sidebar), each call yielding mid-request so the others run inside it. The
thread example fails on the old unguarded snapshot cache. async joins the
test group so the fiber example runs rather than skips.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(registry): search, llms.txt and the sidebar tree run inside a Ractor

conformance-kit's "runs inside a non-main Ractor" canary (Ruby 4.0;
skipped with a reason below) now builds a SearchIndex from literal pages,
assembles llms.txt and llms-full.txt from a Configuration built inside
the Ractor, and builds a literal registry's NavItem tree. Two blockers
were docs-kit's own: Configuration::DEFAULT_NAV was an unshareable Proc
(every Configuration.new read it) and a registry's page list was an
unfrozen Array in a class ivar. The page DSL now makes every value
shareable on write, DEFAULT_NAV is a shareable lambda, and
DocsUI::Callout::LEVELS is frozen all the way down.

Rendering cannot run in a Ractor yet: the Phlex, commonmarker and
Nokogiri canaries are pending with the upstream reason
(yippee-fun/phlex#1055, commonmarker's Proc-defined options, nokogiri's
C extension), and turn red the day one of them passes.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix: make docs-kit's remaining constants Ractor-shareable

conformance-audit (Ruby 4.0) found constants a non-main Ractor could not
read: ApiClient::DEFAULTS held Method objects (never shareable; now
capture-free lambdas), DocsUI::Code::FORMATTER and Engine::JAVASCRIPT_PATH
were unfrozen, and the install generator's MIGRATIONS held an unshareable
block, PACKAGE_FLOORS unfrozen hashes and MIN_RUBY a Gem::Version.
Gem::Version.new hands out process-wide cached instances, so MIN_RUBY and
Migration#to keep a String and build the Gem::Version where they compare.
MigrationRegistry.default is built at load (DEFAULT) instead of an
unguarded `@default ||=`.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix: pass conformance-kit's thread-safety cops, no baseline

.rubocop.yml inherits conformance-kit's config/rubocop.yml (the
rubocop-thread_safety cops, now in the bundle at >= 0.8) and every offence
is fixed rather than baselined:

- DocsKit.configuration is built at load instead of a lazy `||=`, so no
  first request can race to build a second one; the remaining reset is
  boot/spec-only and says so inline.
- DocsUI::Page's class macros (title, eyebrow, description, on_page) make
  each value shareable on write; they run in the class body at load.
- The docs_kit:og task's one-shot screenshot server thread, the store's
  main-only fallback and the release-kit spec's block-form Dir.chdir are
  disabled inline with the reason; the snapshot contention spec, which
  starts threads on purpose, is excluded from NewThread with its reason.
- The scoping spec's stand-in host records around_actions in a closure,
  not a class ivar.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): hold docs-kit to conformance-kit's strict audit

.conformance.yml sets what `conformance-kit check` (and the conformance
workflow on Ruby 4.0) holds docs-kit to: audit.require boots a minimal
Rails app and eager-loads the gem's zeitwerk tree, the engine's
controllers, the generators and the og task's generator; namespace
DocsKit + DocsUI with strict_autoloads (0 unloaded autoloads); the
`rspec --tag ractor` canary; probes for docs-kit.

The strict audit has no unallowlisted finding. main_only carries only
state that truly is main-only, each with its reason: Rails::Engine's,
ActionController's and Thor's own class state, the copied store's
below-3.4 fallback, and DocsKit.@configuration, which holds host
per-request callables by public API.

SIMPLECOV_MINIMUM lets the canary stage (ractor.env) lift the suite's 80%
floor, which a handful of examples cannot meet.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* ci: run the suite and lint on Ruby 4.0 too

The conformance workflow runs the Ractor canary and the strict audit on
Ruby 4.0, so the whole suite and RuboCop run there as well, next to 3.3
and 3.4. AGENTS.md lists the lane and `conformance-kit check`.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs(changelog): the snapshot/OpenAPI locks, finalize! and frozen boot values

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): move to conformance-kit 0.4

The caller's tag and the Gemfile pin move together (the conformance job's
version handshake checks one against the other). No tracked lockfile pins
the kit: the root Gemfile.lock is gitignored, and docs/ doesn't bundle it.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(audit): allowlist framework class state by ancestor, not per ivar

conformance-kit 0.4's ancestor: rules replace the 25 name: entries for the
state Rails::Engine, ActionController::Base and Rails::Generators::Base
(Thor) keep on docs-kit's engine, controllers and generators. Each rule is
narrowed to those frameworks' own ivars, so state docs-kit kept on the same
classes would still be audited. The audit allowlists the same 28 names as
before, with no stale entries; docs-kit's own state (DocsKit.@configuration,
the Store fallback) keeps its name: entries.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(engine): finalize in development too, and again after every reload

The engine skipped DocsKit.finalize! while code reloading was on, so a
development app resolved theme and page view classes per render. It now
finalizes after boot in every environment. With reloading on it also adds a
to_prepare after boot, which runs after the host's to_prepare configure
(that re-opens the configuration) on every reload, so the resolved view
classes are the reloaded ones.

The host's per-request callables are left as they are: they close over app
state, so the configuration stays main-Ractor-only, and finalizing never
tries to make it shareable. A child-process boot spec covers both
environments, a reload, and callables that close over mutable app state.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: DocsKit.finalize! after boot, the host's callables and Ractors

README's Configure section says when the engine finalizes (after boot in
every environment, again after a development reload), what is frozen, and
that the host's callables stay as given, so the configuration is
main-Ractor-only and a Ractor passes its own.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs(changelog): file the conformance work under 1.4.0

The [Unreleased] entries move under a 1.4.0 heading (no date: the release
sets it), with an Upgrading from 1.3 note, the development finalize and the
conformance-kit 0.4 move.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(registry): a new view_namespace drops the views resolved under the old one

Registry#finalize! caches each page's view class by view name. Setting
view_namespace afterwards left that cache in place, so a page kept
rendering the class from the old namespace. The setter now clears it, and
the entries resolve under the new namespace.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(config): keep the OpenAPI memo on its configuration

Configuration#openapi_document cached each Document in a per-Ractor map
keyed by configuration, which kept every configuration that had read a
spec (and its parsed Document) alive for the Ractor's lifetime: one per
reset_configuration! in a host's suite. The memo is back on the
configuration, so a replaced one takes its Document with it. The
check-then-set still runs under a per-Ractor lock, so threads that miss
together parse once and share one Document.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(store): racing threads in a Ractor-local fallback agree on one value

Below 3.4 a non-main Ractor keeps Store values in Ractor-local storage,
which has no lock. The initializer ran inside the ||=, so threads that
missed together each stored and returned their own value, and a cache's
callers could hold different maps and mutexes. The value is now built
first, and the check-then-store runs no Ruby code between the read and the
write: every thread gets the first value stored. The branch had no spec. It
is now covered on every Ruby by stubbing both capability checks, with a
Thread.pass in the initializer to force the race.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(code): build DocsUI::Code::FORMATTER with DocsKit::Shareable.make

The formatter was only shallow-frozen. Rouge's HTML formatter keeps no
ivars today, so that was already shareable, but a Rouge in the allowed
range that does keep state would leave the constant unreadable from a
non-main Ractor. A deep freeze holds for every version.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test: tighten four specs the review found loose

- finalize_spec checks that the two theme names are not looked up again,
  not that no Object.const_get happens at all.
- shareable_spec skips its Ractor examples on an engine without Ractor,
  so the plain-freeze fallback example can run there.
- page_spec checks that each macro stored the value it was given before
  calling it shareable (nil and symbols are always shareable).
- An empty SIMPLECOV_MINIMUM now means the default floor instead of an
  ArgumentError at load.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: only resolved page views skip the lookup at request time

A page whose view class didn't exist at boot is still looked up when it is
read, so it turns up once written. The README promised no lookup at all.

Refs #91

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* ci(dependabot): label conformance-kit updates dependencies + devops

Without labels, Dependabot uses its ecosystem labels (ruby,
github_actions), which aren't in the taxonomy, so its PRs would carry no
type or area label. The labels go on the multi-ecosystem group.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/daisyui that referenced this pull request Oct 15, 2026
…or class merge) (#61)

* chore(ci): call conformance-kit's conformance workflow at v0.3.0

Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot
keep both pins current. The conformance check is not required yet: it
turns green, and becomes required, with #58.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore: re-trigger CI

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): group the two conformance-kit pins into one Dependabot update

A tag bump without the gem (or the reverse) fails the conformance version
check, so both now arrive in a single multi-ecosystem pull request.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(deps): add async and rubocop-thread_safety for the conformance gates

async runs conformance-kit's fiber example (locked on Ruby 3.2 so the 3.2
lane still installs); rubocop-thread_safety >= 0.8 is what the kit's
config/rubocop.yml and `conformance-kit doctor` need.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(class-merge): keep the merger and Tailwind class groups per Ractor

`@tailwind_utils ||=` was an unguarded check-then-set (8 threads built 8
class-group tries), and the merger, its Monitor and the class groups were
module ivars no non-main Ractor could read. DaisyUI::Store (adapted from
conformance-kit's Store, which the gem cannot depend on at runtime, plus
delete) gives each Ractor its own reentrant table; reset! drops the current
Ractor's merger.

Warm merges: 0.60 -> 0.64 us on 4.0.6, 0.64 -> 0.71 us on 3.2.8 (100k, best of 5).

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(class-merge): make the Tailwind config constants Ractor-shareable

The validator, scale and theme lambdas (tailwind_config.rb:48 included) and
DEFAULTS were frozen but not shareable, so a non-main Ractor could not read
them. They are all capture-free (THEME_* reads only its frozen key) with the
module as self, so Ractor.make_shareable takes them as they are; no
conversion to methods needed. DaisyUI::Shareable keeps the old deep freeze
on engines without Ractor.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(config): DaisyUI.finalize! makes the configuration and registries Ractor-shareable

A non-main Ractor can only read a class/module ivar whose value is
shareable. finalize! eager-loads the components, derives each
component_class and freezes their registries, then deep-freezes the
configuration (class_merge and modifiers included) and warms the Tailwind
class groups, all on the main Ractor.

Writers keep working: register_modifiers now assigns new registries instead
of mutating them, and configure after finalize! yields a copy, swaps it in
and finalizes it again. Only a direct write to the frozen configuration
raises. The unknown-modifier report log moved per Ractor (DaisyUI::Store),
since its Mutex made the configuration unshareable; a callable
unknown_modifiers is made shareable when it is capture-free, and kept as it
is otherwise. DaisyUI.@configuration is created at load instead of by a
lazy ||=.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(class-merge): merge classes inside a non-main Ractor

Only the main Ractor can eager-load (Zeitwerk's registry is main-only), so
DaisyGroups skips it elsewhere: finalize! already loaded everything. The
:ractor canary merges inside Ractor.new on Ruby 4.0. Rendering stays
pending on Phlex (Phlex::ATTRIBUTE_CACHE, yippee-fun/phlex#1055); DaisyUI's
own render-time code gets that far.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(lint): inherit conformance-kit's RuboCop config

Turns on the rubocop-thread_safety cops. lib needs no exclusion beyond the
justified inline disables on the component registries and the Store's
below-3.4 table; the three specs that start threads on purpose say so on
the line.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* refactor(mcp): make McpServer::TOOLS Ractor-shareable

Only the outer Array was frozen; the audit flagged the Hashes inside.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): add .conformance.yml with a strict, eager-loaded audit

audit.require loads the gem and runs DaisyUI.finalize!, as the engine does
at boot, which eager-loads every DaisyUI constant (strict_autoloads: 0
skipped). The one main_only entry is Phlex's own register_element registry
on Calendar, which Phlex never reads back. `conformance-kit check` passes
on Ruby 4.0.6.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(engine): finalize DaisyUI once a Rails app has booted

config.after_initialize runs DaisyUI.finalize! after the app's initializers
have configured it. Checked against the docs app (Rails 8, Ruby 4.0.6):
the configuration is shareable after boot, a non-main Ractor merges
classes, and the docs suite passes with and without CI eager loading.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: DaisyUI.finalize! and class merging in Ractors

README and CHANGELOG cover finalize!, configure after it, the FrozenError
on direct writes, and what a callable unknown_modifiers needs to stay
shareable. AGENTS.md lists `conformance-kit check`.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore: re-trigger CI now that Actions is enabled

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(deps): move to conformance-kit 0.4.0

The caller workflow tag and the Gemfile pin move together, so the
conformance version check keeps passing.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): allowlist Phlex's element registry by ancestor, not by name

conformance-kit 0.4 rules cover the ivar Phlex::SGML::Elements writes on
every component, so a component that registers an element later needs
no new entry. The allowlisted names are unchanged
(DaisyUI::Calendar.@__registered_elements__).

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(config): finalizing keeps a value it cannot make shareable

DaisyUI.finalize! raised at boot for a raw class_merge validator that
closes over app state (Ractor::IsolationError from make_shareable), and
Ractor.make_shareable freezes as it walks, so an object holding a logger
would have been left frozen before it failed. Shareable.share freezes
Hashes, Arrays and Strings, makes a capture-free Proc shareable, and
keeps anything else as it is: the configuration is then readable on the
main Ractor only, and finalizing never raises. The gem's own constants
keep the strict Shareable.make.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(config): DaisyUI.configure finalizes and publishes the configuration

A Rails-less app no longer has to call DaisyUI.finalize! itself:
configure is its boot point. It yields the configuration (a writable copy
once one is finalized), finalizes it and only then publishes it, so
calling configure again keeps working and no reader, another thread or a
non-main Ractor, meets a writable configuration. The Rails engine still
finalizes in after_initialize, for an app that never calls configure and
for the components loaded during boot.

The suite's configuration is now frozen, as an app's is: specs change
settings through DaisyUI.configure, or run on a writable copy (:finalizes).

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(docs): the engine finalizes DaisyUI when the app boots

A real Rails boot (the docs app) with no DaisyUI code of its own: the
configuration and the component registries are shareable, and a
non-main Ractor merges classes. Fails without the engine's
after_initialize hook.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs(changelog): this release's changes under 2.1.0

Auto-finalize at boot, the frozen configuration (FrozenError on a
direct write) and values kept as they are when they cannot be made
shareable. The notes that sat under [Unreleased] shipped in 2.0.x.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(config): configure always yields a copy, so a raising block changes nothing

On the first call configure yielded the live configuration, so other
threads could see a half-changed configuration and a block that raised
left its writes behind. It now always works on a copy and publishes it
only once finalized.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(class-merge): rebuild a Ractor's merger when configure publishes

ClassMerge.reset! drops only the calling Ractor's merger, so a Ractor
that had already merged kept the old settings after DaisyUI.configure.
Each merger now remembers the configuration it was built from and is
rebuilt once another one is published.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(base): freeze component registries as they are written

A component that loaded after boot (lazy loading in development), or
registered modifiers after it, kept mutable registries until the next
finalize!, so a non-main Ractor building its merger raised
Ractor::IsolationError. Every registry writer now shares the value it
writes, and component_class is remembered on the main Ractor only.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test: make the shareable, contention and Store specs able to fail

- Store's contention example built nil (Thread.pass && Object.new), so
  every thread got the same nil whatever fetch did.
- The tailwind_utils contention setup is now proven to reach the build.
- The constants spec walks every module under ClassMerge instead of a
  hand-kept list.
- The non-Ractor contexts say they are simulated.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: qualify the any-Ractor guarantee; merger rebuild and frozen registries

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(config): serialize configure, so concurrent calls keep each other's settings

Since configure works on a copy, two concurrent calls copied the same
configuration and the last to publish dropped the other's changes. A
per-Ractor Monitor (DaisyUI::Store) now serializes the copy, yield and
publish, so each call starts from what the previous one published.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(class-merge): build a merger from one configuration snapshot

build_merger recorded the configuration it captured, but DaisyGroups.build
read the prefix and modifiers of whatever configuration was published at
that moment. The configuration is now passed through merger_config to
DaisyGroups.build.

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs(changelog): registry writers replace values only as of this release

Refs #58

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): label the grouped conformance-kit Dependabot PRs

Without labels Dependabot tags its PRs ruby/github_actions, which are
not in the label taxonomy; dependencies + devops are.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon added a commit to zoolutions/phlex-reactive that referenced this pull request Oct 16, 2026
)

* chore(ci): call conformance-kit's conformance workflow at v0.3.0

Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot
keep both pins current. The conformance check is not required yet: it
turns green, and becomes required, with #364.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): group the two conformance-kit pins into one Dependabot update

A tag bump without the gem (or the reverse) fails the conformance version
check, so both now arrive in a single multi-ecosystem pull request.

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(core): per-Ractor Store instead of Concurrent::Map constants

Fragment::VIEWER_WARNED and FragmentsController::CSRF_TOKEN_PATTERNS were
Concurrent::Map constants: unreadable from a non-main Ractor, and
concurrent-ruby is not a declared dependency (it only arrives through
ActiveSupport). The defer/pending once-per-process warnings wrote a module
ivar on the request path, which a non-main Ractor can never do.

Phlex::Reactive::Store is per-Ractor lazy state (Ractor.store_if_absent,
with one Mutex-guarded table on an engine without Ractors), the pattern of
conformance-kit's ConformanceKit::Store copied in because the kit is a
development dependency only. Store::Table is the Mutex-guarded Hash one
Ractor's threads share. lib/ and app/ no longer reference Concurrent::.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(core): finalize the configuration at boot (Phlex::Reactive.finalize!)

A non-main Ractor can read a module ivar only when its value is
Ractor-shareable and can never write one, so after boot every reader must
return a shareable value and no reader may write on first use.

- Every setting starts at its default when the gem loads (SETTING_DEFAULTS),
  so the readers are plain attr_readers. The lazy ones left are the verifier
  (it needs Rails.application) and the param-type registry (ParamSchema
  loads ActiveModel), both built at boot by finalize!.
- Settings are read and written through two private helpers, the one place
  with an inline ThreadSafety/ClassInstanceVariable disable and the reason.
- finalize! builds the lazy defaults, freezes the param-type and schema
  registries, and stores each setting as a deep-frozen shareable copy
  (Phlex::Reactive::Shareable.share), never freezing the app's own objects.
  The verifier (an ActiveSupport::MessageVerifier) and Procs whose self is
  not shareable stay as they are, main-Ractor only.
- Writers keep working after it and what they write is shared too;
  around_action / on_action_error / register_token_upgrader replace a frozen
  list instead of appending. finalized? and a test-only reset_finalization!.
- The engine finalizes from an after_initialize block registered after the
  app's config/initializers, so an app's own after_initialize block that
  appends to authorization_errors still finds the list open.

The lib/phlex/reactive.rb, defer.rb and pending.rb ThreadSafety excludes go:
those files no longer need them.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(controller): instrument verify_authorized at boot, lock the lazy path

Authorization.instrument! ran on the request path: a Module#prepend and an
instance_variable_set on the component class per first request, with a
check-then-set race (two threads could prepend two interceptors).

With eager loading the engine now instruments every registered component
class at boot (Authorization.instrument_all!, from finalize_boot!), so the
endpoint's call is a bare "already instrumented" read. The lazy path stays
for development and for a class defined after boot, and holds a per-Ractor
lock (Phlex::Reactive::Store) across the check and the prepend. The spec
forces the race with a Queue handshake: two interceptors before, one now.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* refactor(component): warm every component's registries at boot

Component::Registry built each class's resolution cache, and Identity and
the DSL their per-class token and root-attrs memos, on the first request
that rendered the class: a write on the component class from the request
path, which a non-main Ractor cannot do and request threads race on.

Registry.warm!(classes) resolves every registry kind (Registry::READERS,
one reader per OWN_IVARS kind, checked by the spec) and builds those memos,
then freezes the class's resolution cache; a frozen cache answers a kind it
lacks without memoizing it. The engine runs it from finalize_boot! when the
app eager-loads. A declaration after it (a development reload) bumps the
generation and rebuilds a fresh cache, as before.

Identity.per_class_gid_app? was a lazy module-ivar memo read on the token
path; it is now a constant computed when the file loads.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* refactor(lint): inherit conformance-kit's RuboCop config, no ThreadSafety excludes

.rubocop.yml inherits conformance-kit: config/rubocop.yml (every
rubocop-thread_safety cop on, MutableClassInstanceVariable with literals,
LazySynchronizationPrimitive) and loses the file and directory excludes of
ClassInstanceVariable and ClassAndModuleAttributes. Fixed rather than
excluded:

- Streamable.register starts a class's view-context generation at 0 when
  the class is defined, so the render path's reader is a plain attr_reader.
- The APM adapter memo is per-Ractor state (Store), not a module ivar.
- Registry reads its generation through its reader.
- Every ActiveSupport::Concern module seals the bookkeeping Concern keeps on
  it (@_dependencies frozen, the included block made shareable) at the end
  of its body (Shareable.seal_concern).

Inline disables, each with the reason it is safe on the line(s) above: the
per-class render memos (warmed at boot by Registry.warm!), the view-context
generation bump (to_prepare / specs, main Ractor), the APM subscription
(installed at boot), and the dummy's test-fixture dials. NewThread is
excluded only for the five spec files that start threads on purpose.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* refactor(core): per-Ractor locks and weak maps, none in constants or ivars

A Mutex, a WeakMap and a WeakKeyMap are never Ractor-shareable, so held in
a constant or a module ivar no other Ractor can even read them. The three
the gem keeps are now per-Ractor state (Phlex::Reactive::Store):

- Component::Registry's generation-bump lock (was the WRITE_MUTEX constant).
- Streamable's class registry: a ClassRegistry (WeakMap + Mutex) per Ractor.
  Classes are defined on the main Ractor (boot, a development reload), so
  in practice it is the one registry it was.
- Pending::Markup's variant memo: a Variants holder (WeakKeyMap + Mutex).

Behaviour is unchanged on the main Ractor; the audit no longer has to
allowlist any of them.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(core): Ractor canary for the pure operations, on Ruby 4.0

conformance-kit's "runs inside a non-main Ractor" example, tagged :ractor
and skipped below Ruby 4.0 with the kit's reason, over what a non-main
Ractor can run once the configuration is finalized: param coercion through
a compiled ParamSchema, Phlex::Reactive::Stream wrapping, Streamable#dom_id,
reading the finalized configuration, and token-payload upgrades.

ParamSchema::BOOLEAN_TYPE is now Ractor-shareable: the canary found the
:boolean caster unreadable from a non-main Ractor.

Two canaries are pending, each with its upstream reason, and fail loudly
the day they pass: signing/verifying (an ActiveSupport::MessageVerifier
cannot be built or used outside the main Ractor) and rendering (Phlex 2.4.1
reads its unshareable ATTRIBUTE_CACHE on every element with attributes,
yippee-fun/phlex#1055).

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): .conformance.yml, eager-loading audit, CI Postgres for the run

- audit.require boots the dummy app (which finalizes the configuration)
  and loads all of the gem: the engine's app/, lib/ through the gem's own
  loader, the two ActiveJob classes left out of eager loading, the MCP tools
  and the browser test helpers. strict_autoloads is on, nothing skipped.
- main_only lists only state that is not the gem's to fix, each with its
  reason: Rails::Engine's and ActionController's own class state on the
  gem's engine and controllers, the ActiveSupport::MessageVerifier, and the
  mcp gem's tool DSL state.
- The Ractor examples and the audit run with pgbus present against
  Postgres (ractor.env TRANSPORT=pgbus). bin/conformance-db is ractor.setup:
  in GitHub Actions it gives the runner's user a role on the workflow's
  postgres:18 service through its postgres/postgres superuser, so the
  user-less DATABASE_URL works there and locally (your own user, your own
  phlex_reactive_pgbus_test), then loads the schema.
- probes: gem_name and roundhouse_app (spec/dummy).

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(docs): resolve conformance-kit in the docs app's bundle

RuboCop in docs/ reads the gem's .rubocop.yml as an ancestor config, and
that one now inherits conformance-kit's config (inherit_gem), so the docs
app's `rake lint` failed with "Unable to find gem conformance-kit". The
docs Gemfile already mirrors the root's RuboCop plugins for the same
reason; it now lists conformance-kit too (development group, require:
false).

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(devtools): phlex_reactive:doctor reports Ractor readiness

A ractor_readiness check, the same shape as the fiber isolation one:
eager loading on, the configuration finalized, and every setting a non-main
Ractor can read (Doctor.unshareable_settings names each one that holds an
unshareable value, with its class). Advisory only, never a failure: no app
has to run Ractors, and the ok line names what still blocks Ractor mode
upstream (Rails, ActiveSupport's MessageVerifier, Phlex rendering,
yippee-fun/phlex#1055). It never loads a server.

Phlex::Reactive::CONFIG_READERS is the one list of configuration readers,
shared by the doctor and the finalize spec.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(core): renderer by name follows a code reload

A renderer given as a Class is that class object for good: once Zeitwerk
reloads it in development, re-renders and broadcasts keep running the old,
unloaded class (reproduced by the new spec). Phlex::Reactive.renderer now
also takes a name, like base_controller_name: it resolves on every read, so
a reloaded class is picked up and the per-thread view contexts rebuild for
it (they are keyed on the renderer's identity). finalize! keeps the name.
A Class keeps working as before. The README and the install template show
the name.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(core): thread and Async contention examples for the hot paths

conformance-kit's "thread-safe under contention" and "fiber-safe under an
Async reactor" examples over rendering a component, building a replace
stream, signing/verifying a token, coercing params, a component class's
first registry resolution, and broadcasting. Tagged :concurrency, and the
conformance run's test_command now runs them too (--tag ractor --tag
concurrency), with pgbus present there: the broadcast examples go over
pgbus in that run and over Action Cable in the regular suite.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(core): name the markup comparison of the contention examples

The autocorrected lambda nested one implicit `it` inside another; a named
method reads unambiguously.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* perf(core): seed the param-type registry when the gem loads

ParamSchema#coerce reads Phlex::Reactive.param_types once per scalar param,
and the finalize-at-boot change had made that reader go through the
settings helper (a lazy "build on first use" read). Seeding the registry
below the loader makes it a plain attr_reader again. ParamSchema loads
ActiveModel, which the engine's boot already required (it freezes the
registry in after_initialize).

coerce_params bench, same machine, 3 runs each (i/s): before the branch
35.2k / 39.8k / 38.3k, after 39.4k / 40.1k / 39.8k; 200 objects per call
both ways.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(bench): what the per-fiber view-context rebuild costs a Falcon request

benchmark/micro/view_context.rb measures building the off-request view
context, then a full action round trip through the Rack stack twice: in one
fiber (Puma: the Thread.current caches stay warm) and each request in a new
fiber (Falcon: they rebuild). The difference is what a shared pool of view
contexts could save a Falcon request.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(core): RBS signatures for the public surface, validated in Lint

sig/ declares the configuration and token surface of Phlex::Reactive, a
component's class-level DSL (Component::DSL::ClassMethods), Streamable's
class methods, Response and ParamSchema, plus Store and Shareable. Rails,
Turbo and Phlex objects are `untyped`: those gems ship no RBS. The gem now
ships sig/.

- Lint runs `bundle exec rbs -I sig validate` (rbs added to the
  development/test group; it was already in the lockfile through rdoc).
- spec/phlex/rbs_spec.rb is a reflection diff: every public method the gem
  defines on those surfaces must have a signature, and a missing one is
  named.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* ci: report-only kino smoke of the dummy app in Ractor mode

The allowed-to-fail kino-smoke job of #364, in this repo's own main.yml:
the dummy app served by kino (-m ractor) through ractor-rails-shim on Ruby
4.0.6, GET /up, then one reactive action (CounterComponent#increment).
scripts/kino_smoke.sh writes `kino --check`, both statuses and the first
blocker to the job summary and an artifact; continue-on-error, so it never
fails the build. Today, locally: kino --check passes (the shim makes the app
shareable), and both requests 500 in the worker Ractors (a worker crash,
then Rails' ActionDispatch::ParamBuilder.default is nil there).

kino and the shim live in gemfiles/kino.gemfile only (eval_gemfile of the
gem's Gemfile), never in the gem's bundle; that gemfile keeps no lockfile,
so the job installs the bundle fresh. The dummy gains Rails' /up route.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: a Concurrency & Ractors guide page

The tiers phlex-reactive is held to and their gates, why request state is
fiber-local (and what the per-fiber view-context rebuild costs a Falcon
request, measured), the configuration finalized at boot and what it changes
for an app, what Ractor mode needs (eager loading) and what waits upstream
(Phlex rendering, yippee-fun/phlex#1055; ActiveSupport's MessageVerifier;
Rails), and the doctor's Ractor readiness line.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: CHANGELOG entries for the concurrency conformance work

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(component): Sortable requires json, which it calls while it loads

Component::Sortable builds SORTABLE_MESSAGES_JSON with Hash#to_json in its
module body, so eager-loading the gem in a process that had not loaded json
yet raised NoMethodError (phlex-forms' conformance audit hit it and worked
around it with its own require). The eager-load spec now boots a fresh
process without json and eager-loads the namespace.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): conformance-kit 0.4.0, workflow caller at v0.4.0

The caller, both Gemfile pins (root and docs) and their lockfiles move to
0.4.0 together. docs/Gemfile pins the kit too, so Dependabot's bundler
entry now watches "/" and "/docs".

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* chore(ci): main_only ancestor: rules for framework class state

conformance-kit 0.4.0 covers the class state a framework keeps on the
gem's own subclasses with one ancestor: rule per framework. Rails::Engine
(4 ivars), ActionController::Base (3 ivars on the two controllers) and
MCP::Tool (the tools' DSL state) replace 16 name: entries; each rule is
narrowed with ivars: to exactly the framework's ivars, so state the gem
keeps on the same classes is still audited. The verifier stays a name:
entry: it is the gem's own setting.

conformance-audit --strict allowlists the same 17 names before and after,
with no stale entries.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(core): finalizing at boot never raises, even without a secret_key_base

finalize! builds the verifier at boot so a request never writes it. An app
booting without a secret_key_base (a production rake task or build step)
made Rails raise ArgumentError there, where the verifier used to be built
on first use. It now stays lazy in that case: boot finalizes, and the first
token raises as before.

The boot spec also covers what the brief asks for: the engine's hook
finalizes with an initializer's closures over app state, a Logger and an
APM object configured, keeping each one working and unfrozen; and every
registry an app registers into is frozen at boot with no call of its own.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: the configuration is finalized at boot, in the README too

The README's configuration section says what finalize! changes: lists
frozen after boot, unshareable values kept on the main Ractor (finalizing
never raises for them), and finalize! as the call for a boot without the
engine. The Concurrency & Ractors page says the same.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: CHANGELOG section for 0.16.0, with the behaviour changes up front

This PR's entries move under ## [0.16.0], led by "Upgrading from 0.15.x":
the configuration finalized automatically at boot, its lists (and every
setting) frozen afterwards, and nothing an app configures making boot raise.

The entries left under [Unreleased] had all shipped (tags 0.14.2 to 0.15.5
cut no sections). They now sit under ## [0.15.5] with a consolidation note,
as 0.9.0 did for 0.2.7 to 0.4.8, so none of them reads as new in 0.16.0.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(core): the registry example drives the boot hook from a reset state

It read the registries as the dummy booted them, but other examples reset
the param-schema registry without refreezing it, so under some seeds it
saw an open registry. It now reopens every registry, runs the engine's
boot hook and asserts each one is frozen, like the examples beside it.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(core): Shareable copies Procs and keeps a Hash's default

From the PR review (cubic):

- seal_concern called Ractor.make_shareable with no Ractor check, so on
  TruffleRuby or JRuby every concern in the gem raised NameError as it
  loaded. It is a no-op without Ractor now.
- share made a capture-free Proc shareable in place, freezing the app's
  own Proc; it shares a copy, as it does for plain data.
- A Hash lost its default value, a Hash with a default proc was only
  shallow-copied (the app's nested values stayed shared with the
  setting), and compare_by_identity was dropped. Every Hash is now a
  plain copy with shared entries that keeps all three.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* feat(core): boot warms client-only components and shares their memos

From the PR review (cubic):

- A ClientBindings-only view never registers with Streamable, so the boot
  hook never warmed it, and its first render built the root-attrs memos
  on the class. Every class with the DSL is now tracked
  (Component::Registry.track, from ClientBindings' included and inherited
  hooks), and the boot hook warms all of them. verify_authorized's
  interceptor still goes only on the token-bearing classes.
- warm! built the render path's memos but left them unshareable (an Array
  of pairs, an attribute Hash). They are the gem's own plain data, so
  warm! now stores shared copies a non-main Ractor can read.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* test(core): tighter conformance specs, and no state leaks between them

From the PR review (cubic):

- The contention examples also require every render to carry a signed
  token (they compared the markup with the token stripped).
- The viewer-warning race starts all eight threads from a barrier.
- The Ractor canary puts the module's settings back after finalizing them.
- The APM memo example exercises the memo (a stubbed available SDK).
- The renderer-reload teardown restores the stored setting, so a name
  is not turned into a class for the rest of the suite.
- finalize_spec asserts every value it set; the boot-hook example
  registers into every registry before asserting it is frozen.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(rbs): signatures match what the methods do, and cover more of the surface

From the PR review (cubic):

- reactive_dirty's only: takes a single name; reactive_lazy's tag: a
  String; reactive_compute is a setter that needs inputs: or outputs:
  (two overloads); reactive_collection?, reactive_compute? and
  reactive_compute_def always raise (bot); Response.dismiss_attr returns
  a String; the removed Response.with is signed like the other removed
  verbs (ActiveSupport's Object#with hid it from the reflection diff).
- around_wrapper is a proc type taking the continuation block (an RBS
  method block cannot declare one, so around_action's comment says so).
- Store::NATIVE is a boolean on an engine without Ractor too, as signed.
- The reflection diff covers Streamable's instance methods (#id, dom_id,
  to_stream_*), Store and Shareable; Streamable's are signed.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* docs: review fixes to the doctor's advice, the CHANGELOG and the bench note

From the PR review (cubic):

- The doctor's Ractor-readiness fix covered only callables; it now says
  how to share any other value, and that one which cannot be shared stays
  main-Ractor only.
- CHANGELOG: the doctor entry says which settings it reports, the
  conformance entry names NewThread's five spec-file excludes, the RBS
  entry lists the wider surface, and boot warming covers client-only
  views.
- The view-context bench describes its delta as an upper bound: it
  includes creating the fiber.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* ci: bounded kino-smoke requests, its server log kept, Dependabot labels

- Every curl in the kino smoke has a --max-time, so a worker Ractor that
  accepts and never answers is recorded instead of hanging the job; the
  artifact keeps kino's full server.log too (cubic review).
- The conformance-kit Dependabot group labels its PRs dependencies +
  devops, from the zoolutions taxonomy, instead of Dependabot's default
  ruby / github_actions labels.

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU

* fix(core): Shareable shares a Hash's default proc too

The copy kept the caller's default proc as is, so a Hash whose default
proc could be shared still came back unshareable (cubic review).

Refs #364

Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
@mhenrixon mhenrixon moved this to Waiting on maintainers in Ractor-safe Rails Oct 18, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant