jsx_rosetta 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "types"
4
4
  require_relative "module_shape_classifier"
5
+ require_relative "../ast/inflector"
5
6
 
6
7
  module JsxRosetta
7
8
  module IR
@@ -67,8 +68,53 @@ module JsxRosetta
67
68
  useReducer useImperativeHandle useLayoutEffect useDebugValue
68
69
  ].freeze
69
70
 
71
+ # Apollo Client hooks. `useQuery` / `useLazyQuery` / `useSubscription`
72
+ # take a GraphQL document as the first argument; `useMutation` returns
73
+ # a `[mutate, { loading, ... }]` tuple. None of these have a direct
74
+ # translation — they encode data fetching, which in Rails lives in
75
+ # the controller/model. Captured here so the backend can emit a
76
+ # per-hook TODO with the operation name preserved when extractable.
77
+ APOLLO_HOOKS = %w[
78
+ useQuery useLazyQuery useMutation useSubscription useApolloClient
79
+ ].freeze
80
+
81
+ # Next.js navigation hooks (App Router and Pages Router). Each has a
82
+ # Rails-side analog:
83
+ # useRouter → controller actions / redirect_to
84
+ # usePathname → request.path
85
+ # useSearchParams → params
86
+ # useParams → params (route params)
87
+ # useSelectedLayoutSegment(s) → not directly translatable; usually
88
+ # used to highlight nav links — the Rails view can pattern-match
89
+ # against request.path.
90
+ NEXT_HOOKS = %w[
91
+ useRouter usePathname useSearchParams useParams
92
+ useSelectedLayoutSegment useSelectedLayoutSegments
93
+ ].freeze
94
+
95
+ FRAMEWORK_HOOKS_BY_LIBRARY = {
96
+ react: REACT_HOOKS,
97
+ apollo: APOLLO_HOOKS,
98
+ next_js: NEXT_HOOKS
99
+ }.freeze
100
+
70
101
  JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
71
102
 
103
+ # Mirrors React's `isUnitlessNumber` table — CSS properties that take
104
+ # a bare number rather than a length. Numeric style values for any
105
+ # property NOT in this set get a `px` suffix appended at lowering time.
106
+ UNITLESS_CSS_PROPERTIES = %w[
107
+ animation-iteration-count aspect-ratio border-image-outset
108
+ border-image-slice border-image-width box-flex box-flex-group
109
+ box-ordinal-group column-count columns flex flex-grow flex-negative
110
+ flex-order flex-positive flex-shrink font-weight grid-area
111
+ grid-column grid-column-end grid-column-span grid-column-start
112
+ grid-row grid-row-end grid-row-span grid-row-start line-clamp
113
+ line-height opacity order orphans scale tab-size widows z-index
114
+ zoom fill-opacity flood-opacity stop-opacity stroke-dasharray
115
+ stroke-dashoffset stroke-miterlimit stroke-opacity stroke-width
116
+ ].to_set.freeze
117
+
72
118
  # Pre-lowering AST scan: maps a node type to a callable returning the
73
119
  # AST nodes that contribute return values. Used by body_returns_jsx?.
74
120
  JSX_RETURN_PROBES = {
@@ -112,6 +158,13 @@ module JsxRosetta
112
158
  @stimulus_methods = []
113
159
  @stimulus_seen_names = {}
114
160
  @react_hooks = []
161
+ @render_methods = []
162
+ @render_method_seen = {}
163
+ # Class-component non-render members (constructor, lifecycle hooks,
164
+ # custom handlers). Keyed by class name; populated by
165
+ # extract_class_component, drained by lower_component to surface
166
+ # the verbatim sources as a TODO comment block.
167
+ @pending_class_other_members = {}
115
168
  end
116
169
 
117
170
  def lower_file(file)
@@ -210,6 +263,7 @@ module JsxRosetta
210
263
  return false if name.nil? || name.empty?
211
264
  return true if pascal_case?(name)
212
265
  return false if hook_name?(name)
266
+ return true if extract_data_factory_array(function)
213
267
 
214
268
  body_returns_jsx?(function[:body])
215
269
  end
@@ -247,6 +301,8 @@ module JsxRosetta
247
301
  [[stmt[:id]&.[](:name), stmt]]
248
302
  when "VariableDeclaration"
249
303
  extract_arrow_components(stmt)
304
+ when "ClassDeclaration"
305
+ extract_class_component(stmt)
250
306
  when "ExportNamedDeclaration", "ExportDefaultDeclaration"
251
307
  extract_exported_components(stmt[:declaration])
252
308
  else
@@ -260,10 +316,133 @@ module JsxRosetta
260
316
  case declaration.type
261
317
  when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
262
318
  when "VariableDeclaration" then extract_arrow_components(declaration)
319
+ when "ClassDeclaration" then extract_class_component(declaration)
263
320
  else []
264
321
  end
265
322
  end
266
323
 
324
+ # Recognize a class component by the presence of a `render()` method.
325
+ # We don't require `extends React.Component` because TypeScript codebases
326
+ # often declare the parent via an `extends` of a typed alias. The render
327
+ # method's signature (no args, returns JSX) is the JSX-component signal.
328
+ #
329
+ # The render ClassMethod's `[:params]` is always `[]` and `[:body]` is a
330
+ # BlockStatement — same shape as a function declaration's body, so the
331
+ # rest of the lowering pipeline works unchanged. Other class members
332
+ # (constructor, lifecycle hooks, custom handlers) get stashed on
333
+ # `@pending_class_other_members` keyed by class name, then surfaced as
334
+ # a LocalBinding-style TODO block by `lower_component`.
335
+ def extract_class_component(class_decl)
336
+ name = class_decl[:id]&.[](:name)
337
+ return [] unless name
338
+
339
+ render_method, other_members = partition_class_members(class_decl)
340
+ return [] unless render_method
341
+
342
+ @pending_class_other_members[name] = other_members
343
+ [[name, render_method]]
344
+ end
345
+
346
+ def partition_class_members(class_decl)
347
+ body = class_decl.child(:body)
348
+ return [nil, []] unless body
349
+
350
+ render_method = nil
351
+ others = []
352
+ body[:body].each do |member|
353
+ if class_render_method?(member)
354
+ render_method = member
355
+ else
356
+ others << member
357
+ end
358
+ end
359
+ [render_method, others]
360
+ end
361
+
362
+ def class_render_method?(member)
363
+ return false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
364
+
365
+ key = member.child(:key)
366
+ AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
367
+ end
368
+
369
+ # Surface every non-render class member (constructor, lifecycle
370
+ # methods like componentDidMount / componentDidCatch / getDerivedStateFromError,
371
+ # custom event handlers) as a LocalBinding-shaped TODO with the
372
+ # verbatim JS source preserved. The user either translates each to a
373
+ # Ruby method by hand or moves the behavior to Stimulus / controllers.
374
+ def absorb_class_other_members(name)
375
+ members = @pending_class_other_members.delete(name) || []
376
+ members.each do |member|
377
+ source = source_of(member).strip
378
+ member_name = class_member_label(member)
379
+ @local_bindings << LocalBinding.new(name: member_name, source: source)
380
+ end
381
+ end
382
+
383
+ def class_member_label(member)
384
+ key = member.child(:key)
385
+ return "<class member>" unless key
386
+
387
+ case key.type
388
+ when "Identifier" then key[:name]
389
+ when "StringLiteral" then key[:value]
390
+ else "<class member>"
391
+ end
392
+ end
393
+
394
+ # Pre-scan the render method body for `this.props.X` member access
395
+ # patterns. Each unique X becomes a synthesized IR::Prop entry on the
396
+ # component, so the generated class emits a matching `initialize(x:)`
397
+ # and the translator (which sees `@x`) resolves cleanly. Without this
398
+ # scan, render references would land in `unresolved_identifiers` and
399
+ # the generated initializer would be empty.
400
+ def absorb_class_render_props(render_method)
401
+ body = render_method.child(:body)
402
+ return [] unless body
403
+
404
+ prop_names = []
405
+ scan_this_props(body, prop_names)
406
+ prop_names.uniq.map { |name| Prop.new(name: name, default: nil, alias_name: nil) }
407
+ end
408
+
409
+ def scan_this_props(node, accumulator)
410
+ return unless node.is_a?(AST::Node)
411
+
412
+ if node.of_type?("MemberExpression") && this_props_access?(node)
413
+ accumulator << node[:property][:name]
414
+ elsif node.of_type?("VariableDeclarator") && this_props_destructure?(node)
415
+ destructured_names_of(node[:id]).each { |name| accumulator << name }
416
+ end
417
+ node.each_child { |child| scan_this_props(child, accumulator) }
418
+ end
419
+
420
+ # Match `this.props.X` exactly — `this.props` member access where the
421
+ # property side is also a MemberExpression. We don't follow deeper
422
+ # chains here; only the immediate `.X` after `.props` becomes a prop
423
+ # name. `this.props.foo.bar` still yields prop name `foo`.
424
+ def this_props_access?(member_expr)
425
+ object = member_expr.child(:object)
426
+ return false unless AST::Node.matches?(object, "MemberExpression")
427
+ return false unless AST::Node.matches?(object.child(:object), "ThisExpression")
428
+
429
+ object_prop = object.child(:property)
430
+ AST::Node.matches?(object_prop, "Identifier") && object_prop[:name] == "props"
431
+ end
432
+
433
+ # `const { foo, bar } = this.props;` — destructure off this.props. Each
434
+ # destructured name becomes a prop. We don't need to walk the rest of
435
+ # the chain because the destructure consumes one level of `.props`.
436
+ def this_props_destructure?(declarator)
437
+ init = declarator[:init]
438
+ return false unless AST::Node.matches?(init, "MemberExpression")
439
+ return false unless AST::Node.matches?(init.child(:object), "ThisExpression")
440
+ return false unless destructure_pattern?(declarator[:id])
441
+
442
+ prop = init.child(:property)
443
+ AST::Node.matches?(prop, "Identifier") && prop[:name] == "props"
444
+ end
445
+
267
446
  def extract_arrow_components(variable_declaration)
268
447
  variable_declaration[:declarations].filter_map do |declarator|
269
448
  init = declarator[:init]
@@ -280,46 +459,129 @@ module JsxRosetta
280
459
  raise lowering_error("anonymous component functions are not supported", node: function)
281
460
  end
282
461
 
462
+ reset_per_component_state!
283
463
  props, rest_prop_name = lower_params(function[:params])
284
464
  @prop_names = props.map(&:name)
285
- @local_bindings = []
286
- @local_binding_names = []
287
- @local_arrows = {}
288
- @local_polymorphic_tags = {}
289
- @local_destructures = {}
290
- @stimulus_methods = []
291
- @stimulus_seen_names = {}
292
- @react_hooks = []
293
-
294
- body = lower_function_body(function[:body])
295
-
465
+ absorb_class_metadata(name, function, props) if function.of_type?("ClassMethod", "MethodDefinition")
466
+
467
+ body, mode = lower_component_body(function)
468
+ # Unconsumed local arrows (`const handleClick = () => ...` that didn't
469
+ # become a Stimulus method or a render-method) are still real bindings
470
+ # in the source. Add their names here so the translator treats use
471
+ # sites as known-unresolvable — `on_click: handle_click` (NameError)
472
+ # becomes `on_click: nil` (file loads, marker visible upstream).
473
+ unconsumed_arrow_names = @local_arrows.keys
296
474
  Component.new(
297
475
  name: name,
298
476
  props: props,
299
477
  body: body,
300
478
  rest_prop_name: rest_prop_name,
301
479
  local_bindings: @local_bindings,
302
- local_binding_names: @local_binding_names.uniq,
480
+ local_binding_names: (@local_binding_names + unconsumed_arrow_names).uniq,
303
481
  module_bindings: [],
304
482
  stimulus_methods: @stimulus_methods,
305
- react_hooks: @react_hooks
483
+ react_hooks: @react_hooks,
484
+ render_methods: @render_methods,
485
+ mode: mode
306
486
  )
307
487
  end
308
488
 
489
+ # A "data factory" function — common for AG-Grid / antd column
490
+ # descriptor modules — is a function whose body just returns an array
491
+ # of object literals (`export const createColumns = (...) => [{...},
492
+ # {...}]`). When we recognize this shape, we lower the body via the
493
+ # recursive ObjectLiteral/ArrayLiteral path (Gap H) and let the
494
+ # backend emit a snake_case method that returns the data, instead of
495
+ # a `view_template`. JSX inside object properties still extracts to
496
+ # private methods on the class via the IR::Lambda extraction.
497
+ def lower_component_body(function)
498
+ factory_array = extract_data_factory_array(function)
499
+ return [lower_value_expression(factory_array), :data_factory] if factory_array
500
+
501
+ [lower_function_body(function[:body]), :view]
502
+ end
503
+
504
+ def extract_data_factory_array(function)
505
+ body = function[:body]
506
+ return nil unless body.is_a?(AST::Node)
507
+
508
+ # Implicit-return arrow: body IS the ArrayExpression.
509
+ return body if data_factory_candidate_array?(body)
510
+
511
+ return nil unless body.of_type?("BlockStatement")
512
+
513
+ return_stmt = body[:body].last
514
+ return nil unless AST::Node.matches?(return_stmt, "ReturnStatement")
515
+
516
+ arg = return_stmt[:argument]
517
+ data_factory_candidate_array?(arg) ? arg : nil
518
+ end
519
+
520
+ def data_factory_candidate_array?(node)
521
+ return false unless AST::Node.matches?(node, "ArrayExpression")
522
+
523
+ # At least one element should be an object literal — otherwise
524
+ # this is probably a primitive list, which doesn't warrant the
525
+ # extra emission machinery and can stay as a regular
526
+ # `body_returns_jsx?` rejection.
527
+ node[:elements].any? { |el| AST::Node.matches?(el, "ObjectExpression") }
528
+ end
529
+
530
+ def reset_per_component_state!
531
+ @local_bindings = []
532
+ @local_binding_names = []
533
+ @local_arrows = {}
534
+ @local_polymorphic_tags = {}
535
+ @local_destructures = {}
536
+ @stimulus_methods = []
537
+ @stimulus_seen_names = {}
538
+ @react_hooks = []
539
+ @render_methods = []
540
+ @render_method_seen = {}
541
+ end
542
+
543
+ def absorb_class_metadata(name, render_method, props)
544
+ absorb_class_other_members(name)
545
+ props.concat(absorb_class_render_props(render_method))
546
+ @prop_names = props.map(&:name)
547
+ end
548
+
309
549
  def lower_params(params)
310
550
  return [[], nil] if params.nil? || params.empty?
311
551
 
552
+ # Multi-positional params — typical for data-factory functions
553
+ # like `createColumns(token, sortedInfo)` and lowercase JSX-helpers
554
+ # like `getAlertIcon(level, status, token, isSnoozed = false)`.
555
+ # Each becomes a Prop with no default. We support Identifier and
556
+ # `AssignmentPattern` (default values get dropped — translating
557
+ # JS defaults to Ruby isn't worth the risk here). Other shapes
558
+ # in a multi-param signature fall through to legacy first-param-
559
+ # only handling so we don't regress files that used to translate.
560
+ if params.size > 1 && params.all? { |p| multi_param_supported?(p) }
561
+ return [params.map { |p| Prop.new(name: multi_param_name(p), default: nil, alias_name: nil) }, nil]
562
+ end
563
+
312
564
  first_param = params.first
313
565
  case first_param.type
314
566
  when "ObjectPattern"
315
567
  lower_object_pattern_params(first_param)
316
568
  when "Identifier"
317
- [[Prop.new(name: first_param[:name], default: nil)], nil]
569
+ [[Prop.new(name: first_param[:name], default: nil, alias_name: nil)], nil]
318
570
  else
319
571
  raise lowering_error("unsupported parameter shape: #{first_param.type}", node: first_param)
320
572
  end
321
573
  end
322
574
 
575
+ def multi_param_supported?(param)
576
+ return true if AST::Node.matches?(param, "Identifier")
577
+
578
+ AST::Node.matches?(param, "AssignmentPattern") && AST::Node.matches?(param[:left], "Identifier")
579
+ end
580
+
581
+ def multi_param_name(param)
582
+ param.type == "Identifier" ? param[:name] : param[:left][:name]
583
+ end
584
+
323
585
  def lower_object_pattern_params(pattern)
324
586
  props = []
325
587
  rest_name = nil
@@ -347,7 +609,20 @@ module JsxRosetta
347
609
  # `nil # TODO: ...`. The trailing `#` comment inside a method
348
610
  # parameter list swallows the closing `)` and breaks Ruby syntax.
349
611
  default = (lower_value_expression(value[:right]) if value.type == "AssignmentPattern")
350
- Prop.new(name: prop_name, default: default)
612
+ alias_name = destructure_alias_for(prop_name, value)
613
+ Prop.new(name: prop_name, default: default, alias_name: alias_name)
614
+ end
615
+
616
+ # `{ "data-testid": dataTestId }` — extract the alias so use sites of
617
+ # `dataTestId` resolve to the prop's `@data_testid` ivar instead of
618
+ # leaking as a bare snake_case ref. Returns nil for the non-aliased
619
+ # case (`{ loading }` — key.name == value.name).
620
+ def destructure_alias_for(prop_name, value)
621
+ target = value.type == "AssignmentPattern" ? value[:left] : value
622
+ return nil unless AST::Node.matches?(target, "Identifier")
623
+ return nil if target[:name] == prop_name
624
+
625
+ target[:name]
351
626
  end
352
627
 
353
628
  def lower_function_body(body)
@@ -614,29 +889,54 @@ module JsxRosetta
614
889
  init = declarator[:init]
615
890
  return unless init.is_a?(AST::Node)
616
891
 
617
- is_hook = hook_call?(init)
618
- @react_hooks << ReactHookCall.new(hook: init[:callee][:name], source: source_of(stmt).strip) if is_hook
892
+ # `const { foo, bar } = this.props` — destructured names already
893
+ # got synthesized into `props:` by absorb_class_render_props at
894
+ # class-component setup time. Skip the LocalBinding TODO + the
895
+ # local_binding_names capture so the translator picks up the prop
896
+ # form (`@foo`) instead of emitting a `nil` placeholder.
897
+ return if this_props_destructure?(declarator)
619
898
 
620
- id_node = declarator[:id]
621
- if destructure_pattern?(id_node)
622
- # Capture destructured names so the ExpressionTranslator recognizes
623
- # them as known-local bindings and emits a `nil` placeholder
624
- # instead of a bare unresolved reference (which NameErrors at
625
- # render time). Hook destructures (`const [open, setOpen] = useState(0)`)
626
- # contribute names but not a separate LocalBinding TODO — the
627
- # hook's source already shows the binding to the reviewer.
628
- # Also track member-expression destructures (Gap J) so
629
- # `const { Content } = Layout` lets `<Content/>` resolve to
630
- # `Layout::Content`.
631
- record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
632
- return
633
- end
899
+ library = hook_library_for(init)
900
+ record_hook_call(stmt, init, library) if library
634
901
 
635
- return if is_hook
902
+ id_node = declarator[:id]
903
+ return handle_destructure_binding(stmt, declarator, init, seen, !library.nil?) if destructure_pattern?(id_node)
904
+ return handle_identifier_hook_binding(id_node) if library
636
905
 
637
906
  name = id_node&.[](:name)
638
907
  return unless name
639
908
 
909
+ dispatch_identifier_binding(stmt, init, name, seen)
910
+ end
911
+
912
+ def record_hook_call(stmt, call_expression, library)
913
+ @react_hooks << ReactHookCall.new(
914
+ hook: call_expression[:callee][:name],
915
+ source: source_of(stmt).strip,
916
+ library: library,
917
+ operation: apollo_operation_name(call_expression, library)
918
+ )
919
+ end
920
+
921
+ # `const [a, b] = ...` or `const { a, b } = ...`. Capture every bound
922
+ # name so the translator recognizes them as known locals. Hook
923
+ # destructures (`const [open, setOpen] = useState(0)`) contribute
924
+ # names but not a separate LocalBinding TODO — the hook's source
925
+ # already shows the binding to the reviewer.
926
+ def handle_destructure_binding(stmt, declarator, init, seen, is_hook)
927
+ record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
928
+ end
929
+
930
+ # Identifier-bound hook result (`const handleChange = useCallback(...)`).
931
+ # The hook source is already in @react_hooks; just mark the binding
932
+ # name as known-local so use sites translate to `nil` instead of a
933
+ # bare snake_case ref that NameErrors at render time.
934
+ def handle_identifier_hook_binding(id_node)
935
+ name = id_node&.[](:name)
936
+ @local_binding_names << name if name
937
+ end
938
+
939
+ def dispatch_identifier_binding(stmt, init, name, seen)
640
940
  case init.type
641
941
  when "JSXElement", "JSXFragment"
642
942
  @local_jsx[name] = init
@@ -717,16 +1017,50 @@ module JsxRosetta
717
1017
 
718
1018
  def detect_bare_hook_call(stmt)
719
1019
  expr = stmt.child(:expression)
720
- return unless expr&.of_type?("CallExpression") && hook_call?(expr)
1020
+ return unless expr&.of_type?("CallExpression")
721
1021
 
722
- @react_hooks << ReactHookCall.new(hook: expr[:callee][:name], source: source_of(stmt).strip)
1022
+ library = hook_library_for(expr)
1023
+ return unless library
1024
+
1025
+ record_hook_call(stmt, expr, library)
723
1026
  end
724
1027
 
725
1028
  def hook_call?(call_expression)
726
- return false unless call_expression.of_type?("CallExpression")
1029
+ !hook_library_for(call_expression).nil?
1030
+ end
1031
+
1032
+ # Resolve a CallExpression to the library whose hook set its callee
1033
+ # belongs to (`:react`, `:apollo`, `:next_js`), or nil when it isn't
1034
+ # a recognized hook invocation. Lookup is by bare-Identifier callee
1035
+ # only — member-expression callees (`Apollo.useQuery`) aren't
1036
+ # recognized; we follow what production code actually writes.
1037
+ def hook_library_for(call_expression)
1038
+ return nil unless call_expression.is_a?(AST::Node) && call_expression.of_type?("CallExpression")
727
1039
 
728
1040
  callee = call_expression.child(:callee)
729
- callee&.of_type?("Identifier") && REACT_HOOKS.include?(callee[:name])
1041
+ return nil unless callee&.of_type?("Identifier")
1042
+
1043
+ name = callee[:name]
1044
+ FRAMEWORK_HOOKS_BY_LIBRARY.each do |library, names|
1045
+ return library if names.include?(name)
1046
+ end
1047
+ nil
1048
+ end
1049
+
1050
+ # For Apollo's document-first hooks (`useQuery(GET_USERS, ...)`),
1051
+ # extract the operation name from a bare-Identifier first argument
1052
+ # so the backend can echo it in the TODO. Returns nil for inline
1053
+ # documents (`gql\`...\``), member-expression args, or non-Apollo
1054
+ # hooks — the caller already has the verbatim source in `source`,
1055
+ # which surfaces those cases to the reviewer.
1056
+ def apollo_operation_name(call_expression, library)
1057
+ return nil unless library == :apollo
1058
+
1059
+ args = call_expression[:arguments]
1060
+ first_arg = args.is_a?(Array) ? args.first : nil
1061
+ return nil unless AST::Node.matches?(first_arg, "Identifier")
1062
+
1063
+ first_arg[:name]
730
1064
  end
731
1065
 
732
1066
  # Recognize the asChild-style polymorphic tag pattern:
@@ -899,7 +1233,66 @@ module JsxRosetta
899
1233
 
900
1234
  def lower_call_expression(expression)
901
1235
  loop_node = try_lower_map_loop(expression)
902
- loop_node || Interpolation.new(expression: source_of(expression))
1236
+ return loop_node if loop_node
1237
+
1238
+ local_call = try_lower_local_arrow_call(expression)
1239
+ return local_call if local_call
1240
+
1241
+ Interpolation.new(expression: source_of(expression))
1242
+ end
1243
+
1244
+ # Recognize `{renderHeader()}` where `renderHeader` is a locally-bound
1245
+ # arrow whose body returns JSX. Extract the arrow as a RenderMethod
1246
+ # on the component and emit a LocalRenderCall at this use site so the
1247
+ # backend can call the generated method instead of dropping the
1248
+ # expression as "[untranslated: renderHeader()]". Args must be simple
1249
+ # identifiers (props, locals) since we don't translate arbitrary
1250
+ # argument expressions here — the backend's ExpressionTranslator
1251
+ # handles them via the Interpolation it sees.
1252
+ def try_lower_local_arrow_call(call_expression)
1253
+ match = local_arrow_call_match(call_expression)
1254
+ return nil unless match
1255
+
1256
+ # Consume the arrow so it doesn't ALSO get promoted to a Stimulus
1257
+ # method if it later appears in event-handler position.
1258
+ @local_arrows.delete(match[:callee_name])
1259
+
1260
+ method_name = unique_render_method_name(match[:callee_name])
1261
+ @render_methods << RenderMethod.new(
1262
+ name: method_name,
1263
+ params: match[:arrow][:params].map { |p| p[:name] },
1264
+ body: match[:body]
1265
+ )
1266
+
1267
+ LocalRenderCall.new(
1268
+ method_name: method_name,
1269
+ args: match[:args].map { |arg| Interpolation.new(expression: source_of(arg)) }
1270
+ )
1271
+ end
1272
+
1273
+ def local_arrow_call_match(call_expression)
1274
+ callee = call_expression.child(:callee)
1275
+ return nil unless callee&.of_type?("Identifier")
1276
+
1277
+ arrow = @local_arrows[callee[:name]]
1278
+ return nil unless arrow
1279
+ return nil unless arrow[:params].all? { |p| AST::Node.matches?(p, "Identifier") }
1280
+
1281
+ args = call_expression[:arguments]
1282
+ return nil if args.size != arrow[:params].size
1283
+ return nil unless args.all? { |a| AST::Node.matches?(a, "Identifier", "MemberExpression") }
1284
+
1285
+ body = lower_lambda_body(arrow[:body])
1286
+ return nil unless body
1287
+
1288
+ { callee_name: callee[:name], arrow: arrow, args: args, body: body }
1289
+ end
1290
+
1291
+ def unique_render_method_name(js_name)
1292
+ snake = AST::Inflector.underscore(js_name)
1293
+ @render_method_seen[snake] ||= 0
1294
+ @render_method_seen[snake] += 1
1295
+ @render_method_seen[snake] == 1 ? snake : "#{snake}_#{@render_method_seen[snake]}"
903
1296
  end
904
1297
 
905
1298
  def try_lower_map_loop(call_expression)
@@ -1061,21 +1454,33 @@ module JsxRosetta
1061
1454
  end
1062
1455
  return nil if property_name.nil?
1063
1456
 
1064
- value = lower_style_value(property[:value])
1457
+ value = lower_style_value(property[:value], property_name)
1065
1458
  return nil if value.nil?
1066
1459
 
1067
1460
  StyleDeclaration.new(property: property_name, value: value)
1068
1461
  end
1069
1462
 
1070
- def lower_style_value(value)
1463
+ def lower_style_value(value, property_name)
1071
1464
  case value.type
1072
1465
  when "StringLiteral" then value[:value]
1073
- when "NumericLiteral" then value[:value].to_s
1466
+ when "NumericLiteral" then numeric_style_value(value[:value], property_name)
1074
1467
  when "Identifier", "MemberExpression"
1075
1468
  Interpolation.new(expression: source_of(value))
1076
1469
  end
1077
1470
  end
1078
1471
 
1472
+ # Mirrors React's `isUnitlessNumber` table: properties that take bare
1473
+ # numbers (`zIndex: 5`) rather than lengths. Everything else gets a
1474
+ # `px` suffix appended when the JSX source provides a unitless number
1475
+ # — `marginBottom: 16` → `margin-bottom: 16px;`. Without this, the
1476
+ # browser silently ignores the declaration as invalid CSS.
1477
+ def numeric_style_value(number, property_name)
1478
+ return number.to_s if number.zero?
1479
+ return number.to_s if UNITLESS_CSS_PROPERTIES.include?(property_name)
1480
+
1481
+ "#{number}px"
1482
+ end
1483
+
1079
1484
  def css_property_from_camel(name)
1080
1485
  name.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
1081
1486
  end
@@ -78,9 +78,28 @@ module JsxRosetta
78
78
  name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
79
79
  end
80
80
 
81
+ # Only flag class-component shape when the class has no usable render
82
+ # method. Classes WITH `render()` lower via the
83
+ # ClassDeclaration → ViewComponent path added in v0.5.0, so the
84
+ # classifier should leave them alone and let the regular component
85
+ # finder pick them up.
81
86
  def class_component?(stmt)
82
87
  decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
83
- AST::Node.matches?(decl, "ClassDeclaration")
88
+ return false unless AST::Node.matches?(decl, "ClassDeclaration")
89
+
90
+ !class_has_render_method?(decl)
91
+ end
92
+
93
+ def class_has_render_method?(class_decl)
94
+ body = class_decl.child(:body)
95
+ return false unless body
96
+
97
+ body[:body].any? do |member|
98
+ next false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
99
+
100
+ key = member.child(:key)
101
+ AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
102
+ end
84
103
  end
85
104
 
86
105
  # Recognize `export const X = React.memo(...)` (export wrapper) or a