Skip to content

Commit

Permalink
Add SGML#fragment for marking selective rendering blocks (#842)
Browse files Browse the repository at this point in the history
- [x] Implement `SGML#fragment` method
- [x] Support fragments inside of a cached block
  • Loading branch information
willcosgrove authored Feb 4, 2025
2 parents 318a861 + bdd2e75 commit abd53a6
Show file tree
Hide file tree
Showing 13 changed files with 397 additions and 41 deletions.
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 @@ -42,37 +42,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 @@ -99,6 +105,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 @@ -117,6 +124,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 @@ -132,6 +140,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 @@ -153,6 +162,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 @@ -209,7 +227,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 @@ -222,7 +240,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 @@ -240,7 +258,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 @@ -326,6 +359,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 @@ -350,6 +384,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

0 comments on commit abd53a6

Please sign in to comment.