Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SGML#fragment for marking selective rendering blocks #842

Merged
merged 7 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ AllCops:
# We need to disable this cop because it’s not compatible with TruffleRuby 23.1, which still needs a `require "set"`
Lint/RedundantRequireStatement:
Enabled: false

Layout/ExtraSpacing:
Enabled: false
1 change: 1 addition & 0 deletions lib/phlex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module Phlex
autoload :Helpers, "phlex/helpers"
autoload :Kit, "phlex/kit"
autoload :NameError, "phlex/errors/name_error"
autoload :NullCacheStore, "phlex/null_cache_store"
autoload :SGML, "phlex/sgml"
autoload :SVG, "phlex/svg"
autoload :Vanish, "phlex/vanish"
Expand Down
79 changes: 78 additions & 1 deletion lib/phlex/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,101 @@ def initialize(user_context: {}, view_context: nil)
@buffer = +""
@capturing = false
@user_context = user_context
@fragments = nil
@fragment_depth = 0
@cache_stack = []
@halt_signal = nil
@view_context = view_context
end

attr_accessor :buffer, :capturing, :user_context

attr_reader :view_context
attr_reader :fragments, :fragment_depth, :view_context

def target_fragments(fragments)
@fragments = fragments.to_set
end

def around_render
return yield if !@fragments || @halt_signal

catch do |signal|
@halt_signal = signal
yield
end
end

def should_render?
!@fragments || @fragment_depth > 0
end

def begin_fragment(id)
@fragment_depth += 1 if @fragments&.include?(id)

if caching?
current_byte_offset = 0 # Start tracking the byte offset of this fragment from the start of the cache buffer
@cache_stack.reverse_each do |(cache_buffer, fragment_map)| # We'll iterate deepest to shallowest
current_byte_offset += cache_buffer.bytesize # Add the length of the cache buffer to the current byte offset
fragment_map[id] = [current_byte_offset, nil, []] # Record the byte offset, length, and store a list of the nested fragments

fragment_map.each do |name, (_offset, length, nested_fragments)| # Iterate over the other fragments
next if name == id || length # Skip if it's the current fragment, or if the fragment has already ended
nested_fragments << id # Add the current fragment to the list of nested fragments
end
end
end
end

def end_fragment(id)
if caching?
byte_length = nil
@cache_stack.reverse_each do |(cache_buffer, fragment_map)| # We'll iterate deepest to shallowest
byte_length ||= cache_buffer.bytesize - fragment_map[id][0] # The byte length is the difference between the current byte offset and the byte offset of the fragment
fragment_map[id][1] = byte_length # All cache contexts will use the same by
end
end

return unless @fragments&.include?(id)

@fragments.delete(id)
@fragment_depth -= 1
throw @halt_signal if @fragments.length == 0
end

def record_fragment(id, offset, length, nested_fragments)
return unless caching?

@cache_stack.reverse_each do |(cache_buffer, fragment_map)|
offset += cache_buffer.bytesize
fragment_map[id] = [offset, length, nested_fragments]
end
end

def caching(&)
buffer = +""
@cache_stack.push([buffer, {}].freeze)
capturing_into(buffer, &)
@cache_stack.pop
end

def caching?
@cache_stack.length > 0
end

def capturing_into(new_buffer)
original_buffer = @buffer
original_capturing = @capturing
original_fragments = @fragments

begin
@buffer = new_buffer
@capturing = true
@fragments = nil
yield
ensure
@buffer = original_buffer
@capturing = original_capturing
@fragments = original_fragments
end

new_buffer
Expand Down
7 changes: 7 additions & 0 deletions lib/phlex/fifo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ def []=(key, value)
def size
@store.size
end

def clear
@mutex.synchronize do
@store.clear
@bytesize = 0
end
end
end
13 changes: 6 additions & 7 deletions lib/phlex/fifo_cache_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,20 @@ def fetch(key)
key = map_key(key)

if (result = fifo[key])
result
JSON.parse(result)
else
result = yield

case result
when String
fifo[key] = result
else
raise ArgumentError.new("Invalid cache value: #{result.class}")
end
fifo[key] = JSON.fast_generate(result)

result
end
end

def clear
@fifo.clear
end

private

def map_key(value)
Expand Down
1 change: 1 addition & 0 deletions lib/phlex/html.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Phlex::HTML < Phlex::SGML
# Output an HTML doctype.
def doctype
context = @_context
return unless context.should_render?

context.buffer << "<!doctype html>"
nil
Expand Down
67 changes: 51 additions & 16 deletions lib/phlex/sgml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,37 +56,43 @@ def to_proc
proc { |c| c.render(self) }
end

def call(buffer = +"", context: {}, view_context: nil, parent: nil, &block)
def call(buffer = +"", context: {}, view_context: nil, parent: nil, fragments: nil, &block)
@_buffer = buffer
@_context = phlex_context = parent&.__context__ || Phlex::Context.new(user_context: context, view_context:)
@_parent = parent

raise Phlex::DoubleRenderError.new("You can't render a #{self.class.name} more than once.") if @_rendered
@_rendered = true

if fragments
phlex_context.target_fragments(fragments)
end

block ||= @_content_block

return "" unless render?

Thread.current[:__phlex_component__] = [self, Fiber.current.object_id].freeze

before_template(&block)
phlex_context.around_render do
before_template(&block)

around_template do
if block
view_template do |*args|
if args.length > 0
__yield_content_with_args__(*args, &block)
else
__yield_content__(&block)
around_template do
if block
view_template do |*args|
if args.length > 0
__yield_content_with_args__(*args, &block)
else
__yield_content__(&block)
end
end
else
view_template
end
else
view_template
end
end

after_template(&block)
after_template(&block)
end

unless parent
buffer << phlex_context.buffer
Expand All @@ -113,6 +119,7 @@ def plain(content)
# Output a single space character. If a block is given, a space will be output before and after the block.
def whitespace(&)
context = @_context
return unless context.should_render?

buffer = context.buffer

Expand All @@ -131,6 +138,7 @@ def whitespace(&)
# [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Comments)
def comment(&)
context = @_context
return unless context.should_render?

buffer = context.buffer

Expand All @@ -146,6 +154,7 @@ def raw(content)
case content
when Phlex::SGML::SafeObject
context = @_context
return unless context.should_render?

context.buffer << content.to_s
when nil, "" # do nothing
Expand All @@ -167,6 +176,15 @@ def capture(*args, &block)
end
end

# Define a named fragment that can be selectively rendered.
def fragment(name)
context = @_context
context.begin_fragment(name)
yield
context.end_fragment(name)
nil
end

# Mark the given string as safe for HTML output.
def safe(value)
case value
Expand Down Expand Up @@ -223,7 +241,7 @@ def render(renderable = nil, &)
# end
# end
# ```
def cache(*cache_key, **options, &content)
def cache(*cache_key, **, &content)
context = @_context

location = caller_locations(1, 1)[0]
Expand All @@ -236,7 +254,7 @@ def cache(*cache_key, **options, &content)
cache_key, # allows for custom cache keys
].freeze

context.buffer << cache_store.fetch(full_key, **options) { capture(&content) }
low_level_cache(full_key, **, &content)
end

# Cache a block of content where you control the entire cache key.
Expand All @@ -254,7 +272,22 @@ def cache(*cache_key, **options, &content)
def low_level_cache(cache_key, **options, &content)
context = @_context

context.buffer << cache_store.fetch(cache_key, **options) { capture(&content) }
cached_buffer, fragment_map = cache_store.fetch(cache_key, **options) { context.caching(&content) }

if context.should_render?
fragment_map.each do |fragment_name, (offset, length, nested_fragments)|
context.record_fragment(fragment_name, offset, length, nested_fragments)
end
context.buffer << cached_buffer
else
fragment_map.each do |fragment_name, (offset, length, nested_fragments)|
if context.fragments.include?(fragment_name)
context.fragments.delete(fragment_name)
context.fragments.subtract(nested_fragments)
context.buffer << cached_buffer.byteslice(offset, length)
end
end
end
end

# Points to the cache store used by this component.
Expand Down Expand Up @@ -340,6 +373,7 @@ def __yield_content_with_args__(*a)

def __implicit_output__(content)
context = @_context
return true unless context.should_render?

case content
when Phlex::SGML::SafeObject
Expand All @@ -364,6 +398,7 @@ def __implicit_output__(content)
# same as __implicit_output__ but escapes even `safe` objects
def __text__(content)
context = @_context
return true unless context.should_render?

case content
when String
Expand Down
7 changes: 7 additions & 0 deletions lib/phlex/sgml/elements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ def #{method_name}(**attributes)
buffer = context.buffer
block_given = block_given?

unless context.should_render?
yield(self) if block_given
return nil
end

if attributes.length > 0 # with attributes
if block_given # with content block
buffer << "<#{tag}" << (Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">"
Expand Down Expand Up @@ -89,6 +94,8 @@ def #{method_name}(**attributes)
context = @_context
buffer = context.buffer

return unless context.should_render?

if attributes.length > 0 # with attributes
buffer << "<#{tag}" << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">"
else # without attributes
Expand Down
13 changes: 13 additions & 0 deletions quickdraw/attribute_cache_expansion.test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

test "using a component without a source location" do
refute_raises do
# Intentionally not passing a source location here.
eval <<~RUBY
class Example < Phlex::HTML
def view_template
end
end
RUBY
end
end
32 changes: 23 additions & 9 deletions quickdraw/caching.test.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
# frozen_string_literal: true

test "using a component without a source location" do
refute_raises do
# Intentionally not passing a source location here.
eval <<~RUBY
class Example < Phlex::HTML
def view_template
end
end
RUBY
CacheStore = Phlex::FIFOCacheStore.new

class CacheTest < Phlex::HTML
def cache_store = CacheStore

def initialize(execution_watcher)
@execution_watcher = execution_watcher
end

def view_template
cache do
@execution_watcher.call
"OK"
end
end
end

test "caching a block only executes once" do
run_count = 0
monitor = -> { run_count += 1 }
CacheTest.new(monitor).call
assert_equal run_count, 1
CacheTest.new(monitor).call
assert_equal run_count, 1
end
Loading