diff --git a/lib/phlex.rb b/lib/phlex.rb index 576378dc..d61c894d 100644 --- a/lib/phlex.rb +++ b/lib/phlex.rb @@ -27,7 +27,15 @@ module Phlex DEPLOYED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) CACHED_FILES = Set.new - ATTRIBUTE_CACHE = FIFO.new(max_bytesize: 2 ** 20) + ATTRIBUTE_CACHE_MAX_BYTESIZE = 2 ** 20 + + # The main Ractor’s attribute cache. A FIFO is not shareable, so a non-main + # Ractor can’t read this constant — it gets its own through Phlex.attribute_cache. + ATTRIBUTE_CACHE = FIFO.new(max_bytesize: ATTRIBUTE_CACHE_MAX_BYTESIZE) + + # Bytes every attribute cache has been expanded by so far (see + # __expand_attribute_cache__), so a Ractor-local cache starts the same size. + @attribute_cache_expansion = 0 UNBOUND_INSTANCE_METHOD_METHOD = Module.instance_method(:instance_method) @@ -35,9 +43,20 @@ module Phlex # Lives here so the runtime can consult it without loading the compiler. COMPILED_SOURCE_MAPS = {} + # A frozen, Ractor-shareable copy of COMPILED_SOURCE_MAPS, republished by the + # compiler after each write, so renders on any Ractor can read it. + @compiled_source_maps = {}.freeze + + def self.__compiled_source_maps__ = @compiled_source_maps + + # Only the compiler calls this, on the main Ractor and under its lock. + def self.__publish_compiled_source_map__(path, generation) + @compiled_source_maps = @compiled_source_maps.merge(path => generation).freeze + end + # The original line for a location that may be in compiled code. def self.__source_line__(location) - if (generation = COMPILED_SOURCE_MAPS[location.path]) + if (generation = @compiled_source_maps[location.path]) generation.lines[location.lineno] || location.lineno else location.lineno @@ -49,8 +68,55 @@ def self.__expand_attribute_cache__(file_path) unless CACHED_FILES.include?(file_path) CACHED_FILES << file_path - Phlex::ATTRIBUTE_CACHE.expand(File.size(file_path)) + bytes = File.size(file_path) + @attribute_cache_expansion += bytes + Phlex::ATTRIBUTE_CACHE.expand(bytes) + end + end + + # The attribute cache for the current Ractor: ATTRIBUTE_CACHE on the main + # Ractor, a Ractor-local FIFO anywhere else (sized like the main one, + # including the expansion for every view_template seen so far). Resolved + # once per render into Phlex::SGML::State, never per element. + # + # Before Ruby 4.0 a non-main Ractor can’t autoload, so on Ruby 3.2 and 3.3 + # call `Phlex::Loader.eager_load` before spawning one. Rubies without Ractors + # (TruffleRuby, JRuby) always use ATTRIBUTE_CACHE. + if !defined?(::Ractor) + def self.attribute_cache + ATTRIBUTE_CACHE end + elsif ::Ractor.respond_to?(:store_if_absent) # Ruby 3.4+ + def self.attribute_cache + if Ractor.main? + ATTRIBUTE_CACHE + else + __sized_attribute_cache__(Ractor.store_if_absent(:__phlex_attribute_cache__) { __new_attribute_cache__ }) + end + end + else + def self.attribute_cache + ractor = Ractor.current + + if ractor == Ractor.main + ATTRIBUTE_CACHE + else + __sized_attribute_cache__(ractor[:__phlex_attribute_cache__] ||= __new_attribute_cache__) + end + end + end + + def self.__new_attribute_cache__ + FIFO.new(max_bytesize: ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion) + end + + # __expand_attribute_cache__ only reaches the main cache, so a Ractor-local + # cache catches up with templates defined since it was created when it's + # next fetched (once per render). + def self.__sized_attribute_cache__(cache) + target = ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion + cache.grow_to(target) if cache.max_bytesize < target + cache end # Generate an HTML string using Phlex’ HTML DSL diff --git a/lib/phlex/compiler.rb b/lib/phlex/compiler.rb index 33cf8527..247c5292 100644 --- a/lib/phlex/compiler.rb +++ b/lib/phlex/compiler.rb @@ -91,10 +91,24 @@ def self.compiled?(component) component.instance_variable_get(:@__phlex_compiled__) == true end + # Only the main Ractor compiles: compiling redefines methods and needs MUTEX, + # neither of which a non-main Ractor can touch. Those render whatever is + # defined, compiled or not. + if !defined?(::Ractor) + def self.main_ractor? = true + elsif ::Ractor.respond_to?(:main?) + def self.main_ractor? = ::Ractor.main? + else + def self.main_ractor? = ::Ractor.current == ::Ractor.main + end + # The failure check and the compilation share the lock, so two first - # renders racing each other still compile and report once. The handler - # runs outside it, since it may well render something. + # renders racing each other still compile and report once, and a render + # that starts while a recompilation holds it waits for the new methods. + # The handler runs outside it, since it may well render something. def self.compile_on_first_render(component) + return unless main_ractor? + error = MUTEX.synchronize do return if compiled?(component) || FAILURES.key?(component) @@ -183,7 +197,10 @@ def self.compile_file(path, components: loaded_components, recompile: false, inl formatting_result.source_map.each_with_index do |original_line, generated_line| lines[generated_line] = original_line if original_line end - MAP[compiled_path] = Generation.new(path:, lines: lines.freeze) + generation = Generation.new(path: -path, lines: lines.freeze) + generation = ::Ractor.make_shareable(generation) if defined?(::Ractor) + MAP[compiled_path] = generation + Phlex.__publish_compiled_source_map__(-compiled_path, generation) results.each do |result| result.visibilities.each do |name, visibility| diff --git a/lib/phlex/fifo.rb b/lib/phlex/fifo.rb index 51a22349..cf9df852 100644 --- a/lib/phlex/fifo.rb +++ b/lib/phlex/fifo.rb @@ -19,6 +19,14 @@ def expand(bytes) end end + # Raises the limit to `bytes` unless it's already that large. Unlike `expand`, + # callers racing to the same target grow it once. + def grow_to(bytes) + @mutex.synchronize do + @max_bytesize = bytes if bytes > @max_bytesize + end + end + def [](key) k, v = @store[key.hash] v if k.eql?(key) diff --git a/lib/phlex/html.rb b/lib/phlex/html.rb index 01905a73..be62fff8 100644 --- a/lib/phlex/html.rb +++ b/lib/phlex/html.rb @@ -58,7 +58,7 @@ def tag(name, **attributes, &) if (tag = StandardElements.__registered_elements__[name]) || ((tag = name.name.tr("_", "-")).include?("-") && tag.match?(/\A[a-z][a-z0-9-]*\z/)) if attributes.length > 0 # with attributes if block_given # with content block - buffer << "<#{tag}" << Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" begin if tag == "svg" render Phlex::SVG.new(&) @@ -69,7 +69,7 @@ def tag(name, **attributes, &) buffer << "" end else # without content - buffer << "<#{tag}" << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" end else # without attributes if block_given # with content block @@ -99,7 +99,7 @@ def tag(name, **attributes, &) Phlex::SGML::Elements.public_send(normalizer, attributes) end - buffer << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end diff --git a/lib/phlex/html/standard_elements.rb b/lib/phlex/html/standard_elements.rb index 1bff3552..f969e902 100644 --- a/lib/phlex/html/standard_elements.rb +++ b/lib/phlex/html/standard_elements.rb @@ -630,4 +630,6 @@ module Phlex::HTML::StandardElements # [MDN Docs](https://developer.mozilla.org/docs/Web/HTML/Element/wbr) # [Spec](https://html.spec.whatwg.org/#the-wbr-element) register_element def wbr(**attributes, &content) = nil + + __freeze_registries__ end diff --git a/lib/phlex/html/void_elements.rb b/lib/phlex/html/void_elements.rb index 3eeb2ca3..c2333df3 100644 --- a/lib/phlex/html/void_elements.rb +++ b/lib/phlex/html/void_elements.rb @@ -63,4 +63,6 @@ module Phlex::HTML::VoidElements # [MDN Docs](https://developer.mozilla.org/docs/Web/HTML/Element/track) # [Spec](https://html.spec.whatwg.org/#the-track-element) __register_void_element__ def track(**attributes) = nil + + __freeze_registries__ end diff --git a/lib/phlex/sgml.rb b/lib/phlex/sgml.rb index 22dd6a51..4c2fe887 100644 --- a/lib/phlex/sgml.rb +++ b/lib/phlex/sgml.rb @@ -434,7 +434,7 @@ def json_escape(string) end private def __attributes__(attributes) - Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + @_state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } end private_class_method def self.method_added(method_name) @@ -503,7 +503,7 @@ def __map_exception__(exception) exception.set_backtrace( locations.map do |loc| - if (generation = Phlex::COMPILED_SOURCE_MAPS[loc.path]) && (line = generation.lines[loc.lineno]) + if (generation = Phlex.__compiled_source_maps__[loc.path]) && (line = generation.lines[loc.lineno]) "#{generation.path}:#{line}:in '#{loc.label}'" else loc.to_s diff --git a/lib/phlex/sgml/elements.rb b/lib/phlex/sgml/elements.rb index 91ca9b7c..b7f390f3 100644 --- a/lib/phlex/sgml/elements.rb +++ b/lib/phlex/sgml/elements.rb @@ -72,6 +72,16 @@ def __registered_void_elements__ @__registered_void_elements__ ||= {} end + # The built-in element modules call this once their bodies have registered + # every element. It freezes both registries in place, without copying them, + # so `tag` can read them from any Ractor. A later registration on a frozen + # registry swaps in a frozen copy instead of mutating it. + def __freeze_registries__ + __freeze_registry__(__registered_elements__) + __freeze_registry__(__registered_void_elements__) + nil + end + def register_element(method_name, tag: method_name.name.tr("_", "-")) # Aliasing the method to itself suppresses Ruby's method redefinition warning. alias_method(method_name, method_name) if method_defined?(method_name) @@ -94,7 +104,7 @@ def #{method_name}(**attributes) buffer << "<#{tag}" begin #{Phlex::SGML::Elements.normalizer_call(method_name)} - buffer << Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end @@ -125,7 +135,7 @@ def #{method_name}(**attributes) buffer << "<#{tag}" begin #{Phlex::SGML::Elements.normalizer_call(method_name)} - buffer << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end @@ -167,8 +177,7 @@ def #{method_name}(**attributes) end RUBY - __registered_elements__[method_name] = tag - __registered_void_elements__.delete(method_name) + __record_element__(method_name, tag, void: false) method_name end @@ -191,7 +200,7 @@ def #{method_name}(**attributes) buffer << "<#{tag}" begin #{Phlex::SGML::Elements.normalizer_call(method_name)} - buffer << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end @@ -203,9 +212,37 @@ def #{method_name}(**attributes) end RUBY - __registered_elements__[method_name] = tag - __registered_void_elements__[method_name] = tag + __record_element__(method_name, tag, void: true) method_name end + + private def __record_element__(method_name, tag, void:) + elements = __registered_elements__ + void_elements = __registered_void_elements__ + + if elements.frozen? || void_elements.frozen? + tag = -tag.to_s + @__registered_elements__ = __freeze_registry__(elements.merge(method_name => tag)) + @__registered_void_elements__ = __freeze_registry__( + void ? void_elements.merge(method_name => tag) : void_elements.except(method_name) + ) + else + elements[method_name] = tag + + if void + void_elements[method_name] = tag + else + void_elements.delete(method_name) + end + end + end + + private def __freeze_registry__(registry) + if defined?(::Ractor) + ::Ractor.make_shareable(registry) + else + registry.each_value(&:freeze).freeze + end + end end diff --git a/lib/phlex/sgml/state.rb b/lib/phlex/sgml/state.rb index b7c0b97a..5a10f1e3 100644 --- a/lib/phlex/sgml/state.rb +++ b/lib/phlex/sgml/state.rb @@ -12,11 +12,12 @@ def initialize(user_context: {}, output_buffer:, fragments:) @cache_stack = [] @halt_signal = nil @output_buffer = output_buffer + @attribute_cache = Phlex.attribute_cache end attr_accessor :capturing, :user_context - attr_reader :fragments, :fragment_depth, :output_buffer, :buffer, :should_render + attr_reader :fragments, :fragment_depth, :output_buffer, :buffer, :should_render, :attribute_cache # Kept as a stored boolean so the check is a plain attribute read. alias_method :should_render?, :should_render diff --git a/lib/phlex/svg.rb b/lib/phlex/svg.rb index a8db0dfb..c5e0a3e4 100644 --- a/lib/phlex/svg.rb +++ b/lib/phlex/svg.rb @@ -46,14 +46,14 @@ def tag(name, **attributes, &) if (tag = StandardElements.__registered_elements__[name]) || ((tag = name.name.tr("_", "-")).include?("-") && tag.match?(/\A[a-z][a-z0-9-]*\z/)) if attributes.length > 0 # with attributes if block_given # with content block - buffer << "<#{tag}" << Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" begin __yield_content__(&) ensure buffer << "" end else # without content - buffer << "<#{tag}" << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" end else # without attributes if block_given # with content block diff --git a/lib/phlex/svg/standard_elements.rb b/lib/phlex/svg/standard_elements.rb index 17fe24d1..76ceafad 100644 --- a/lib/phlex/svg/standard_elements.rb +++ b/lib/phlex/svg/standard_elements.rb @@ -418,4 +418,6 @@ module Phlex::SVG::StandardElements **attributes, &content ) = nil + + __freeze_registries__ end diff --git a/quickdraw/compiler/lifecycle.test.rb b/quickdraw/compiler/lifecycle.test.rb index cdcb9a60..df5e3b37 100644 --- a/quickdraw/compiler/lifecycle.test.rb +++ b/quickdraw/compiler/lifecycle.test.rb @@ -81,6 +81,45 @@ def footer_content = span { "Footer" } end end + test "a lazy render of a compiled component waits for a recompilation in progress" do + with_component_files( + "waits.rb" => <<~RUBY + # frozen_string_literal: true + class LifecycleWaits < Phlex::HTML + def view_template = div { "waits" } + end + RUBY + ) do + Phlex::Compiler.enable! + begin + LifecycleWaits.call + assert Phlex::Compiler.compiled?(LifecycleWaits) + + locked = Queue.new + release = Queue.new + recompiling = Thread.new do + Phlex::Compiler::MUTEX.synchronize do + locked << true + release.pop + end + end + locked.pop + + render = Thread.new { LifecycleWaits.call } + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2 + Thread.pass until render.status == "sleep" || !render.alive? || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + + assert render.alive? + release << true + assert_equal render.value, "
waits
" + recompiling.join + ensure + release&.<< true + Phlex::Compiler.disable! + end + end + end + test "lazy compilation compiles a component on its first render" do with_component_files( "lazy.rb" => <<~RUBY diff --git a/quickdraw/fifo.test.rb b/quickdraw/fifo.test.rb index df047a1f..cbaa5b1d 100644 --- a/quickdraw/fifo.test.rb +++ b/quickdraw/fifo.test.rb @@ -96,4 +96,23 @@ class FIFOTest < Quickdraw::Test assert_equal fifo.fetch(1) { "a" * 11 }, "a" * 11 assert_equal fifo.size, 0 end + + test "grow_to raises the limit to a target and never lowers it" do + fifo = Phlex::FIFO.new(max_bytesize: 10) + + fifo.grow_to(25) + assert_equal fifo.max_bytesize, 25 + + fifo.grow_to(25) + fifo.grow_to(5) + assert_equal fifo.max_bytesize, 25 + end + + test "grow_to from racing threads grows to the target once" do + fifo = Phlex::FIFO.new(max_bytesize: 10) + + Array.new(8) { Thread.new { 100.times { fifo.grow_to(25) } } }.each(&:join) + + assert_equal fifo.max_bytesize, 25 + end end diff --git a/quickdraw/ractor.test.rb b/quickdraw/ractor.test.rb new file mode 100644 index 00000000..822c9a60 --- /dev/null +++ b/quickdraw/ractor.test.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "tmpdir" + +class RactorTest < Quickdraw::Test + class Example < Phlex::HTML + register_element :trix_editor + + def view_template + div(class: "card", id: "example") do + span(data: { count: 1 }) { "Hello" } + img(src: "/a.png", alt: "a") + tag(:p, class: "dynamic") { "Dynamic" } + trix_editor(class: "custom") { "Custom" } + svg(viewBox: "0 0 1 1") { |s| s.path(d: "M0 0") } + end + end + end + + class TagExample < Phlex::HTML + def view_template + tag(:div, class: "a") { "x" } + tag(:img, src: "/a.png", srcset: ["/a.png 1x", "/b.png 2x"]) + tag(:my_widget, data: { k: 1 }) { "w" } + tag(:svg, viewBox: "0 0 1 1") { |s| s.tag(:path, d: "M0 0") } + tag(:br) + end + end + + class Cached < Phlex::HTML + # One store per Ractor: a FIFOCacheStore can't be shared between them. + def cache_store = Ractor.store_if_absent(:ractor_test_cache_store) { Phlex::FIFOCacheStore.new } + + def view_template = cache { div { "cached" } } + end + + def self.run_in_ractor(&) + experimental = Warning[:experimental] + Warning[:experimental] = false + ractor = Ractor.new(&) + ractor.respond_to?(:value) ? ractor.value : ractor.take + ensure + Warning[:experimental] = experimental + end + + def run_in_ractor(&) = self.class.run_in_ractor(&) + + # Ractor-local storage needs Ruby 3.4+; TruffleRuby and JRuby have no Ractors. + RACTORS = defined?(::Ractor) && ::Ractor.respond_to?(:store_if_absent) + + # Phlex escapes text through ERB::Escape, whose html_escape is only marked + # Ractor-safe since erb 5.0.2 — whether the render test runs depends on the + # erb in the bundle (Ruby 4.0 ships a safe one; Ruby 3.3 and 3.4 do not). + ERB_ESCAPE_RACTOR_SAFE = + RACTORS && begin + report = Thread.report_on_exception + Thread.report_on_exception = false + run_in_ractor { ERB::Escape.html_escape("<") } == "<" + rescue ::Ractor::Error + false + ensure + Thread.report_on_exception = report + end + + test "the main Ractor uses ATTRIBUTE_CACHE" do + assert_equal Phlex.attribute_cache.equal?(Phlex::ATTRIBUTE_CACHE), true + end + + if RACTORS + test "the built-in element registries are shareable" do + [Phlex::HTML::StandardElements, Phlex::HTML::VoidElements, Phlex::SVG::StandardElements].each do |elements| + assert Ractor.shareable?(elements.__registered_elements__) + assert Ractor.shareable?(elements.__registered_void_elements__) + end + end + + test "a non-main Ractor gets its own attribute cache" do + main_cache_id = Phlex::ATTRIBUTE_CACHE.object_id + ractor_cache_ids = run_in_ractor { [Phlex.attribute_cache.object_id, Phlex.attribute_cache.object_id] } + + assert_equal ractor_cache_ids.uniq.size, 1 + assert_equal ractor_cache_ids.include?(main_cache_id), false + end + + test "a non-main Ractor's cache is as large as the main one" do + assert_equal run_in_ractor { Phlex.attribute_cache.max_bytesize }, Phlex::ATTRIBUTE_CACHE.max_bytesize + end + + test "a non-main Ractor's cache grows with templates defined after it was created" do + experimental = Warning[:experimental] + Warning[:experimental] = false + ractor = Ractor.new do + Ractor.main << Phlex.attribute_cache.max_bytesize # before + Ractor.receive + Phlex.attribute_cache.max_bytesize # after + end + + before = Ractor.receive + Phlex.__expand_attribute_cache__(File.expand_path("../bench.rb", __dir__)) + expected = Phlex::ATTRIBUTE_CACHE.max_bytesize + + ractor << :go + after = ractor.respond_to?(:value) ? ractor.value : ractor.take + + assert_equal after, expected + assert_equal after > before, true + ensure + Warning[:experimental] = experimental + end + end + + if ERB_ESCAPE_RACTOR_SAFE + test "renders inside a non-main Ractor with the same output" do + assert_equal run_in_ractor { Example.call }, Example.call + end + + test "tag renders inside a non-main Ractor" do + assert_equal run_in_ractor { TagExample.call }, TagExample.call + end + + test "caches a fragment inside a non-main Ractor" do + assert_equal run_in_ractor { [Cached.call, Cached.call] }, [%(
cached
)] * 2 + end + + test "a compiled component's error keeps its class inside a non-main Ractor" do + Dir.mktmpdir do |dir| + path = File.join(dir, "ractor_raising.rb") + File.write(path, <<~RUBY) + # frozen_string_literal: true + class RactorRaising < Phlex::HTML + def view_template + div { raise ArgumentError, "boom" } + end + end + RUBY + load path + Phlex::Compiler.compile(RactorRaising) + + error = begin + run_in_ractor { RactorRaising.call } + nil + rescue Ractor::RemoteError => e + e.cause + end + + assert_equal [error.class, error.message], [ArgumentError, "boom"] + end + end + + test "renders inside a non-main Ractor with lazy compilation enabled" do + Dir.mktmpdir do |dir| + path = File.join(dir, "ractor_lazy.rb") + File.write(path, <<~RUBY) + # frozen_string_literal: true + class RactorLazyCompiled < Phlex::HTML + def view_template = div(class: "compiled") { "compiled" } + end + + class RactorLazyUncompiled < Phlex::HTML + def view_template = div(class: "uncompiled") { "uncompiled" } + end + RUBY + load path + + Phlex::Compiler.enable! + begin + expected = RactorLazyCompiled.call # the main Ractor compiles it + + assert_equal run_in_ractor { RactorLazyCompiled.call }, expected + assert_equal run_in_ractor { RactorLazyUncompiled.call }, %(
uncompiled
) + ensure + Phlex::Compiler.disable! + end + end + end + end +end diff --git a/quickdraw/sgml/element_registry.test.rb b/quickdraw/sgml/element_registry.test.rb new file mode 100644 index 00000000..eb0d128f --- /dev/null +++ b/quickdraw/sgml/element_registry.test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class ElementRegistryTest < Quickdraw::Test + BUILT_INS = [ + Phlex::HTML::StandardElements, + Phlex::HTML::VoidElements, + Phlex::SVG::StandardElements, + ].freeze + + def new_elements_module + Module.new { extend Phlex::SGML::Elements } + end + + test "the built-in registries are frozen once their elements are registered" do + BUILT_INS.each do |elements| + assert elements.__registered_elements__.frozen? + assert elements.__registered_void_elements__.frozen? + assert elements.__registered_elements__.each_value.all?(&:frozen?) + end + end + + test "registering after the freeze replaces the registry with a frozen copy" do + elements = new_elements_module + elements.register_element :foo_bar + elements.__freeze_registries__ + before = elements.__registered_elements__ + + elements.register_element :baz_qux + + assert_equal elements.__registered_elements__, { foo_bar: "foo-bar", baz_qux: "baz-qux" } + assert elements.__registered_elements__.frozen? + assert_equal before, { foo_bar: "foo-bar" } + assert before.frozen? + end + + test "registering a void element after the freeze updates both registries" do + elements = new_elements_module + elements.__register_void_element__ :foo_bar + elements.__freeze_registries__ + + elements.__register_void_element__ :baz_qux + + assert_equal elements.__registered_elements__, { foo_bar: "foo-bar", baz_qux: "baz-qux" } + assert_equal elements.__registered_void_elements__, { foo_bar: "foo-bar", baz_qux: "baz-qux" } + assert elements.__registered_void_elements__.frozen? + end + + test "re-registering a void element as standard after the freeze removes it from the void registry" do + elements = new_elements_module + elements.__register_void_element__ :foo_bar + elements.__freeze_registries__ + + elements.register_element :foo_bar + + assert_equal elements.__registered_void_elements__, {} + assert elements.__registered_void_elements__.frozen? + end + + test "registering after the freeze never freezes the caller's tag" do + elements = new_elements_module + elements.__freeze_registries__ + tag = +"my-tag" + + elements.register_element(:my_tag, tag:) + elements.register_element :other_tag, tag: :"other-tag" + + refute tag.frozen? + assert_equal elements.__registered_elements__, { my_tag: "my-tag", other_tag: "other-tag" } + end + + if defined?(::Ractor) + test "registries stay shareable after a registration that follows the freeze" do + elements = new_elements_module + elements.register_element :foo_bar + elements.__freeze_registries__ + elements.register_element :baz_qux + + assert Ractor.shareable?(elements.__registered_elements__) + assert Ractor.shareable?(elements.__registered_void_elements__) + end + end + + test "a component's own registry stays mutable" do + component = Class.new(Phlex::HTML) do + register_element :trix_editor + + def view_template = trix_editor { "Hello" } + end + + refute component.__registered_elements__.frozen? + assert_equal component.__registered_elements__, { trix_editor: "trix-editor" } + assert_equal component.call, "Hello" + end +end