Skip to content

Data.define subclass fields are nil when module is included in define block #9327

@rossroberts-toast

Description

@rossroberts-toast

Environment Information

  • JRuby 10.0.4.0 (Ruby 3.4.5 compat)
  • macOS arm64-darwin / also reproduced on Linux x86_64 in GitHub Actions CI
  • No special flags, gems, or frameworks required — reproducible with a standalone script
  • Requires # frozen_string_literal: true pragma (or --enable-frozen-string-literal)

Expected Behavior

When a module is included in a Data.define block and the resulting class is subclassed, field accessors on subclass instances should return the values they were initialized with — the same as CRuby.

Given the script jruby_data_include_bug.rb:

#!/usr/bin/env ruby
# frozen_string_literal: true

def check(obj, expected)
  expected.each { |k, v| puts "  #{k}=#{obj.public_send(k).inspect}" }
end

mod = Module.new { def greet = "hello" }
base = Data.define(:x, :y) { include mod }
sub = Class.new(base)

check(sub.new(x: 1, y: 2), {x: 1, y: 2})

with CRuby:
$ ruby jruby_data_include_bug.rb
x=1
y=2

On JRuby 10.0.4.0, the accessors return nil:

$ jruby jruby_data_include_bug.rb
x=nil
y=nil

The bug requires all of the following conditions to be present simultaneously:

  1. A module is included inside the Data.define block
  2. The resulting Data class is subclassed
  3. The subclass instance is constructed inline as a method argument alongside a hash with the same keys as the Data members
  4. public_send is called inside the method with keys from that hash

Removing any one condition makes the bug disappear:

  # frozen_string_literal: true

  def check(obj, expected)
    expected.each { |k, v| puts "  #{k}=#{obj.public_send(k).inspect}" }
  end

  mod = Module.new { def greet = "hello" }
  base = Data.define(:x, :y) { include mod }
  sub = Class.new(base)

  # FAILS: all four conditions present
  check(sub.new(x: 1, y: 2), {x: 1, y: 2})
  # => x=nil, y=nil

  # PASSES: no include
  base2 = Data.define(:x, :y)
  sub2 = Class.new(base2)
  check(sub2.new(x: 1, y: 2), {x: 1, y: 2})
  # => x=1, y=2

  # PASSES: no subclass
  check(base.new(x: 1, y: 2), {x: 1, y: 2})
  # => x=1, y=2

  # PASSES: pre-assigned to variable (not inline)
  obj = sub.new(x: 1, y: 2)
  puts "  x=#{obj.x.inspect}"  # => x=1

This appears to be a kwargs dispatch interaction — the hash {x: 1, y: 2} passed as a positional argument interferes with the Data constructor's x: 1, y: 2 keyword arguments when public_send is later called with the same
symbol keys inside the method body.

This causes real-world breakage in any codebase that uses the pattern class Config < Data.define(...) { include SomeMixin } — which is a common pattern for adding shared behavior to Data value classes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions