diff --git a/javascript/packages/dev-tools/src/index.ts b/javascript/packages/dev-tools/src/index.ts index 7adf7b7..d00999e 100644 --- a/javascript/packages/dev-tools/src/index.ts +++ b/javascript/packages/dev-tools/src/index.ts @@ -1,4 +1,5 @@ import { initHerbDevTools, HerbOverlay, type HerbDevToolsOptions } from "@herb-tools/dev-tools" +import { enhanceLabelsWithRenderTimes } from "./reactionview-overlay" export interface ReActionViewDevToolsOptions extends HerbDevToolsOptions { projectPath?: string @@ -25,6 +26,8 @@ export class ReActionViewDevTools { ...this.options }) + enhanceLabelsWithRenderTimes() + return this.herbOverlay } diff --git a/javascript/packages/dev-tools/src/reactionview-overlay.ts b/javascript/packages/dev-tools/src/reactionview-overlay.ts new file mode 100644 index 0000000..70d75b6 --- /dev/null +++ b/javascript/packages/dev-tools/src/reactionview-overlay.ts @@ -0,0 +1,94 @@ +import { HerbOverlay } from "@herb-tools/dev-tools" + +interface TimingData { + duration: number + template: string + path: string + identifier: string +} + +interface TimingsMap { + [renderId: string]: TimingData +} + +export function enhanceLabelsWithRenderTimes(): void { + const timings = loadTimingData() + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement + + if (element.classList?.contains('herb-overlay-label')) { + enhanceLabel(element, timings) + } else { + element.querySelectorAll?.('.herb-overlay-label').forEach((label) => { + enhanceLabel(label as HTMLElement, timings) + }) + } + } + }) + }) + }) + + observer.observe(document.body, { + childList: true, + subtree: true + }) + + document.querySelectorAll('.herb-overlay-label').forEach((label) => { + enhanceLabel(label as HTMLElement, timings) + }) +} + +function loadTimingData(): TimingsMap { + const script = document.getElementById('reactionview-timings') + if (!script) return {} + + try { + return JSON.parse(script.textContent || '{}') + } catch (e) { + console.error('Failed to parse ReactionView timing data:', e) + return {} + } +} + +function enhanceLabel(label: HTMLElement, timings: TimingsMap): void { + let parent = label.parentElement + while (parent) { + const renderId = parent.getAttribute('data-reactionview-id') + + if (renderId && timings[renderId]) { + const timing = timings[renderId] + const shortName = parent.getAttribute('data-herb-debug-file-name') || timing.template + + if (!(label as any)._reactionviewEnhanced) { + label.textContent = `${shortName} (${timing.duration} ms)` + + label.addEventListener('mouseenter', () => { + label.textContent = `${timing.path} (${timing.duration} ms)` + + document.querySelectorAll('.herb-overlay-label').forEach(otherLabel => { + (otherLabel as HTMLElement).style.zIndex = '1000' + }) + + label.style.zIndex = '1002' + }) + + label.addEventListener('mouseleave', () => { + label.textContent = `${shortName} (${timing.duration} ms)` + label.style.zIndex = '1000' + }) + + ;(label as any)._reactionviewEnhanced = true + } + + break + } + + parent = parent.parentElement + } +} + +export { HerbOverlay } diff --git a/lib/reactionview.rb b/lib/reactionview.rb index aeee3fe..b72e0bf 100644 --- a/lib/reactionview.rb +++ b/lib/reactionview.rb @@ -21,6 +21,9 @@ require_relative "reactionview/template/handlers/herb" require_relative "reactionview/template/handlers/herb/herb" +require_relative "reactionview/timing_visitor" +require_relative "reactionview/middleware/timing_output" + require_relative "reactionview/railtie" if defined?(Rails::Railtie) module ReActionView diff --git a/lib/reactionview/config.rb b/lib/reactionview/config.rb index 56ff3e7..2d57333 100644 --- a/lib/reactionview/config.rb +++ b/lib/reactionview/config.rb @@ -5,11 +5,13 @@ class Config attr_accessor :intercept_erb attr_accessor :debug_mode attr_accessor :transform_visitors + attr_accessor :show_render_times def initialize @intercept_erb = false @debug_mode = nil @transform_visitors = [] + @show_render_times = nil end def development? @@ -29,6 +31,12 @@ def debug_mode_enabled? development? end + + def show_render_times? + return @show_render_times unless @show_render_times.nil? + + debug_mode_enabled? + end end def self.config diff --git a/lib/reactionview/middleware/timing_output.rb b/lib/reactionview/middleware/timing_output.rb new file mode 100644 index 0000000..e4d81ce --- /dev/null +++ b/lib/reactionview/middleware/timing_output.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module ReActionView + module Middleware + class TimingOutput + def initialize(app) + @app = app + end + + def call(env) + Thread.current[:reactionview_timings] = {} + + status, headers, response = @app.call(env) + + response = inject_timing_data(response) if ::ReActionView.config.show_render_times? && html_response?(headers) + + Thread.current[:reactionview_timings] = nil + + [status, headers, response] + end + + private + + def html_response?(headers) + content_type = headers["Content-Type"] + content_type&.include?("text/html") + end + + def inject_timing_data(response) + body = response_body(response) + timings = Thread.current[:reactionview_timings] || {} + + return [body] if timings.empty? + + timing_script = build_timing_script(timings) + + if body.include?("") + body = body.sub("", "#{timing_script}") + elsif body.include?("") + body = body.sub("", "#{timing_script}") + else + body << timing_script + end + + [body] + end + + def response_body(response) + body = +"" + response.each { |part| body << part } + body + end + + def build_timing_script(timings) + timings_json = timings.to_json.gsub("<", "\\u003c").gsub(">", "\\u003e") + + <<~HTML + + HTML + end + end + end +end diff --git a/lib/reactionview/railtie.rb b/lib/reactionview/railtie.rb index 6265b73..b06449e 100644 --- a/lib/reactionview/railtie.rb +++ b/lib/reactionview/railtie.rb @@ -29,6 +29,10 @@ class Railtie < Rails::Railtie end end + initializer "reactionview.middleware" do |app| + app.middleware.use ReActionView::Middleware::TimingOutput if ReActionView.config.show_render_times? + end + config.after_initialize do ActiveSupport.on_load(:action_view) do ActionView::Template.register_template_handler :erb, ReActionView::Template::Handlers::ERB if ReActionView.config.intercept_erb diff --git a/lib/reactionview/template/handlers/herb.rb b/lib/reactionview/template/handlers/herb.rb index 2d86539..c0f3b6d 100644 --- a/lib/reactionview/template/handlers/herb.rb +++ b/lib/reactionview/template/handlers/herb.rb @@ -18,6 +18,14 @@ def call(template, source) ) end + if ::ReActionView.config.show_render_times? && local_template?(template) + visitors << ::ReActionView::TimingVisitor.new( + template_name: File.basename(template.identifier), + template_path: template.identifier.sub("#{Rails.root}/", ""), + template_identifier: template.identifier + ) + end + config = { filename: template.identifier, project_path: Rails.root.to_s, diff --git a/lib/reactionview/timing_visitor.rb b/lib/reactionview/timing_visitor.rb new file mode 100644 index 0000000..7121145 --- /dev/null +++ b/lib/reactionview/timing_visitor.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +module ReActionView + class TimingVisitor < Herb::Visitor + def initialize(template_name:, template_path:, template_identifier:) + super() + + @template_name = template_name + @template_path = template_path + @template_identifier = template_identifier + @top_level_elements = [] + @id_attribute_added = false + @timing_injected = false + end + + def visit_document_node(node) + find_top_level_elements(node) + + inject_timing_start(node) unless @timing_injected + + super + + inject_timing_end(node) unless @timing_injected + + @timing_injected = true + end + + def visit_html_element_node(node) + add_render_id_to_element(node.open_tag) if should_add_render_id?(node) + + super + end + + private + + def inject_timing_start(document_node) + id_generation_node = create_erb_code_node( + "__reactionview_render_id = \"rv_\#{Time.now.to_i}_\#{rand(100000)}\"" + ) + + timing_start_node = create_erb_code_node( + "__reactionview_timing_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)" + ) + + document_node.children.unshift(timing_start_node) + document_node.children.unshift(id_generation_node) + end + + def inject_timing_end(document_node) + timing_end_node = create_erb_code_node( + "__reactionview_timing_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)" + ) + + timing_calculation_node = create_erb_code_node( + "__reactionview_timing_ms = ((__reactionview_timing_end - __reactionview_timing_start) * 1000).round(2)" + ) + + escaped_name = @template_name.gsub("\\", "\\\\\\\\").gsub('"', '\\"') + escaped_path = @template_path.gsub("\\", "\\\\\\\\").gsub('"', '\\"') + escaped_id = @template_identifier.gsub("\\", "\\\\\\\\").gsub('"', '\\"') + + timing_storage_node = create_erb_code_node(<<~RUBY.strip) + Thread.current[:reactionview_timings] ||= {}; + Thread.current[:reactionview_timings][__reactionview_render_id] = { + duration: __reactionview_timing_ms, + template: "#{escaped_name}", + path: "#{escaped_path}", + identifier: "#{escaped_id}" + } + RUBY + + document_node.children << timing_end_node + document_node.children << timing_calculation_node + document_node.children << timing_storage_node + end + + def find_top_level_elements(document_node) + @top_level_elements = [] + + document_node.children.each do |child| + @top_level_elements << child if child.is_a?(Herb::AST::HTMLElementNode) + end + end + + def should_add_render_id?(element_node) + return false if @id_attribute_added + return false unless @top_level_elements.first == element_node + + true + end + + def add_render_id_to_element(open_tag_node) + return if @id_attribute_added + + id_attribute = create_erb_attribute("data-reactionview-id", "__reactionview_render_id") + open_tag_node.children << id_attribute + + @id_attribute_added = true + end + + def create_static_attribute(name, value) + name_node = create_html_attribute_name_node(name) + value_literal = create_literal_node(value) + value_node = create_html_attribute_value_node([value_literal]) + + create_html_attribute_node(name_node, value_node) + end + + def create_erb_attribute(name, ruby_variable) + name_node = create_html_attribute_name_node(name) + erb_node = create_erb_output_node(ruby_variable) + value_node = create_html_attribute_value_node([erb_node]) + + create_html_attribute_node(name_node, value_node) + end + + def create_html_attribute_name_node(name) + name_literal = create_literal_node(name) + + Herb::AST::HTMLAttributeNameNode.new("HTMLAttributeNameNode", dummy_location, [], [name_literal]) + end + + def create_literal_node(string) + Herb::AST::LiteralNode.new("LiteralNode", dummy_location, [], string.dup) + end + + def create_html_attribute_node(name_node, value_node) + equals_token = create_token(:equals, "=") + + Herb::AST::HTMLAttributeNode.new("HTMLAttributeNode", dummy_location, [], name_node, equals_token, value_node) + end + + def create_html_attribute_value_node(children) + Herb::AST::HTMLAttributeValueNode.new( + "HTMLAttributeValueNode", + dummy_location, + [], + create_token(:quote, '"'), + children, + create_token(:quote, '"'), + true + ) + end + + def create_erb_content_node(ruby_code, tag_opening) + tag_opening = create_token(:erb_tag_opening, tag_opening) + content = create_token(:erb_content, " #{ruby_code} ") + tag_closing = create_token(:erb_tag_closing, "%>") + + Herb::AST::ERBContentNode.new( + "ERBContentNode", + dummy_location, + [], + tag_opening, + content, + tag_closing, + nil, # analyzed_ruby + true, # parsed + true # valid + ) + end + + def create_erb_code_node(ruby_code) + create_erb_content_node(ruby_code, "<%") + end + + def create_erb_output_node(ruby_code) + create_erb_content_node(ruby_code, "<%=") + end + + def create_token(type, value) + Herb::Token.new(value.dup, dummy_range, dummy_location, type.to_s) + end + + def dummy_location + @dummy_location ||= Herb::Location.from(0, 0, 0, 0) + end + + def dummy_range + @dummy_range ||= Herb::Range.from(0, 0) + end + end +end