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

Fix have_exact_props RSpec matcher. #105

Merged
merged 2 commits into from
Sep 27, 2023
Merged

Conversation

bknoles
Copy link
Collaborator

@bknoles bknoles commented Sep 25, 2023

Resolves #103

@mrsweaters reported that our recent change for deep merging broke the .have_exact_props matcher in RSpec. This change fixes that situation and cleans up the code a little bit for good measure.

Background

Until v3.1.0, Inertia Rails treated string-keyed props as different than symbol-keyed props. No one ever complained, but as I built out the deep merging feature I kept running into issues while testing with mixed string/symbol keys.

Because everything gets rendered to JSON, there's no reason we need to treat {'a' => 1} any differently than {a: 1}. As part of 3.1.0, I cast both props and shared data to HashWithIndifferentAccess in an attempt to be, well, indifferent to the way in which we write our props. We quickly found that this broke partial reloading, and we fixed that in #102. While HashWithIndifferentAccess has some really useful properties, it was not indifferent when directly comparing keys because they are treated as strings.

The issue reported in #103 is another rough edge of HashWithIndifferentAccess.

The Cause of a Broken have_exact_props

HashWithIndifferentAccess is a subclass of Hash that overwrites a bunch of the getter/setter/command methods to be ambivalent about whether strings are keys. Internally, it converts all keys to string keys. So, when you set a value like this:

indifferent_hash[:x] = 5

Under the hood, HashWithIndifferentAccess does this:

indifferent_hash['x'] = 5

Additionally, if you call .with_indifferent_access on a Hash, Ruby will traverse the entire hash and convert any symbolized keys into string keys.

$(irb): {a: 1}.with_indifferent_access => {'a' => 1}

So, HashWithIndifferentAccess converts symbol keys to string keys and overwrites Hash setters so you always get string keys.

But there's a loophole! If you emit symbolized keys within a .transform_values block, HashWithIndifferentAccess won't convert them into string keys. And you might think (as I did) that you could just call .with_indifferent_access on the resulting object to rebuild it. But HashWithIndifferentAccess.with_indifferent_access just calls .dup on the indifferent hash. It doesn't convert anything. See the source here.

And this is exactly what we were doing with lambda based shared props. Here are Inertia-less RSpec examples illustrating the bug:

describe 'surprising HashWithIndifferentAccess behavior' do
   This passes just fine
  it 'passes just fine when transforming the values of a Hash using .call' do
    string_key_hash = {'a' => ->{{b: 1}}}
    string_key_hash.transform_values!(&:call)
    symbol_key_hash = {a: {b: 1}}
    expect(string_key_hash.with_indifferent_access).to eq symbol_key_hash.with_indifferent_access
  end

   This fails
  it 'fails after transforming symbolized keys within a HashWithIndifferentAccess' do
    string_key_hash = {'a' => ->{{b: 1}}}.with_indifferent_access # Note this is a HashWithIndifferentAccess before transforming values
    string_key_hash.transform_values!(&:call)
    symbol_key_hash = {a: {b: 1}}
    # string_key_hash.with_indifferent_access is just string_key_hash.dup, meaning the symoblized keys
    # emitted by the transform_values! call are not converted to strings.
    expect(string_key_hash.with_indifferent_access).to eq symbol_key_hash.with_indifferent_access
  end
end

The Solution

OK, so now we know what's causing our false negative test results with .have_exact_props. There's a lot of ways to solve it, but they all end up being a version of "make sure you are comparing apples to apples".

I didn't like that my original solution had .with_indifferent_access access scattered around the internal code. I also think that, as evidenced two times now, being indifferent to the key type is a bit of a footgun.

The change here converts our props into hashes-with-symbolized-keys in a single place: the .computed_props method in the renderer, right before we merge the props together. Now the logic is in one place, and a developer can freely mix string and symbol keys in their controllers. InertiaRails will reconcile everything without duplicated keys. The RSpec matchers are also updated to use symbolized keys in order to match the output of .computed_props. Again, it won't matter whether a test uses strings or symbol keys or mixes them. InertiaRails treats them as equivalent.

I believe this is a much cleaner implementation with a smaller chance of unintended consequences.

That said, are there any other footguns from symbolizing keys that I might be missing? This change is tested, and all previous tests have passed, but this and the issues that prompted 3.1.1 both slipped through holes in the test suite.

…e lambda computed props have symbol keys that fail to match equality with HasWithIndifferentAccess's string keys
if rendering_partial_component?
key.to_sym.in? partial_keys
else
!prop.is_a?(InertiaRails::Lazy)
end
end

deep_transform_values(_props, lambda {|prop| prop.respond_to?(:call) ? @controller.instance_exec(&prop) : prop }).with_indifferent_access
deep_transform_values(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a formatting change. I just like reading this on multiple lines better.

Comment on lines +125 to +126
property_a: "some value",
'property_b' => "this value",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is taken directly from the bug report, with one tweak to ensure complete ambivalence towards the key type.

Copy link
Collaborator

@BrandonShar BrandonShar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the explanation was more work than the code 😄

Excellent description and the change makes good sense to me!

@bknoles
Copy link
Collaborator Author

bknoles commented Sep 27, 2023

Haha, it actually did take awhile to figure out which 5 lines needed to be changed but yea, the description might be overkill. I just found it interesting and wanted to share.

@bknoles bknoles merged commit 052b608 into master Sep 27, 2023
18 checks passed
@bknoles bknoles deleted the broken-have-exact-props branch September 27, 2023 01:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Recent change in 3.1.1 breaks have_exact_props
2 participants