Skip to content

Fix EagerDoTag not committing results when partially evaluated#1081

Merged
jasmith-hs merged 6 commits intomasterfrom
fix-new-values-in-do-tag-not-takes
Jun 16, 2023
Merged

Fix EagerDoTag not committing results when partially evaluated#1081
jasmith-hs merged 6 commits intomasterfrom
fix-new-values-in-do-tag-not-takes

Conversation

@jasmith-hs
Copy link
Copy Markdown
Contributor

In EagerDoTag, because of this logic we weren't committing information about variables which were declared inside the {% do %}:

if (
  !eagerExecutionResult.getResult().isFullyResolved() ||
  interpreter.getContext().isDeferredExecutionMode()
) {
  prefixToPreserveState.withAll(eagerExecutionResult.getPrefixToPreserveState());
} else {
  interpreter.getContext().putAll(eagerExecutionResult.getSpeculativeBindings());
}

The do block doesn't create a child scope so variables declared inside of it should propagate outside of the block. This was happening when it was fully resolved, but when it was partially resolved, we weren't committing the results. Specifically, we weren't either deferring the variable or taking any fully resolved values.

To do this, we need to distinguish between which speculative bindings (those child bindings that come from evaluating the {% do %} block) are fully resolved, and which are deferred values.
This distinction is well demonstrated in this test case:

{% do %}
  {% set list1 = ['a'] %}
  {% set list2 = ['b'] %}
  {% do list2.append(deferred) %}
{% enddo %}
L1: {{ list1 }}
L2: {{ list2 }}

list1 is fully resolved when exiting the {% do %} block, but list2 is deferred. We want to commit that information and make the context have list1 = ['a'] and list2 = DeferredValue.instance(['b']). This way we can resolve {{ list1 }}, but not resolve {{ list2 }}. This is what we need, and before this PR, both list1 and list2 would remain null so they'd resolve to nothing.

So to make this change, instead of converting DeferredValues back to their original value anytime we add one to the SpeculativeBindings, we'll only convert back to the original value when calling EagerReconstructionUtils#resetSpeculativeBindings or when creating a set tag to reconstruct the value.

Copy link
Copy Markdown
Contributor

@boulter boulter left a comment

Choose a reason for hiding this comment

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

The description of the solution makes sense, but I'm having trouble grasping what effect each of the code changes you made has and what they actually do. Perhaps you could comment on each change?

@@ -0,0 +1,2 @@
L1: ['a']
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

for short files like these, I generally prefer to inline them in the test so it's easier to see the input and output without flipping between 3 files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a good point, but separating the input and outputs in separate files lets me have very readable and consistent tests.
These three files:
-commits-variables-from-do-tag-when-partially-resolved.jinja
-commits-variables-from-do-tag-when-partially-resolved.expected.jinja
-commits-variables-from-do-tag-when-partially-resolved.expected.expected.jinja

Self-annotate their relationship to each other and describe what they're testing.

Compare this to how a god-test-file will become complex and inconsistent when able to specify any sort of setup and assertions:

@Test
public void itPreservesRandomness() {
String output = interpreter.render("{{ [1,2,3]|shuffle }}");
assertThat(output).isEqualTo("{{ [1,2,3]|shuffle }}");
assertThat(interpreter.getErrors()).isEmpty();
}
@Test
public void itDefersMacro() {
localContext.put("padding", 0);
localContext.put("added_padding", 10);
String deferredOutput = interpreter.render(
getFixtureTemplate("deferred-macro.jinja")
);
Object padding = localContext.get("padding");
assertThat(padding).isInstanceOf(DeferredValue.class);
assertThat(((DeferredValue) padding).getOriginalValue()).isEqualTo(10);
localContext.put("padding", ((DeferredValue) padding).getOriginalValue());
localContext.put("added_padding", 10);
// not deferred anymore
localContext.put("deferred", 5);
localContext.getGlobalMacro("inc_padding").setDeferred(false);
String output = interpreter.render(deferredOutput);
assertThat(output.replace("\n", "")).isEqualTo("0,10,15,25");
}
@Test
public void itDefersAllVariablesUsedInDeferredNode() {
String template = getFixtureTemplate("vars-in-deferred-node.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
String output = interpreter.render(template);
Object varInScope = localContext.get("varUsedInForScope");
assertThat(varInScope).isInstanceOf(DeferredValue.class);
DeferredValue varInScopeDeferred = (DeferredValue) varInScope;
assertThat(varInScopeDeferred.getOriginalValue()).isEqualTo("outside if statement");
HashMap<String, Object> deferredContext = DeferredValueUtils.getDeferredContextWithOriginalValues(
localContext
);
deferredContext.forEach(localContext::put);
String secondRender = interpreter.render(output);
assertThat(secondRender).isEqualTo("outside if statement entered if statement");
localContext.put("deferred", DeferredValue.instance());
localContext.put("resolved", "resolvedValue");
}
@Test
public void itDefersDependantVariables() {
String template = "";
template +=
"{% set resolved_variable = 'resolved' %} {% set deferred_variable = deferred + '-' + resolved_variable %}";
template += "{{ deferred_variable }}";
interpreter.render(template);
localContext.get("resolved_variable");
}
@Test
public void itDefersVariablesComparedAgainstDeferredVals() {
String template = "";
template += "{% set testVar = 'testvalue' %}";
template += "{% if deferred == testVar %} true {% else %} false {% endif %}";
interpreter.render(template);
Object varInScope = localContext.get("testVar");
assertThat(varInScope).isInstanceOf(DeferredValue.class);
DeferredValue varInScopeDeferred = (DeferredValue) varInScope;
assertThat(varInScopeDeferred.getOriginalValue()).isEqualTo("testvalue");
}
@Test
public void itDoesNotPutDeferredVariablesOnGlobalContext() {
String template = getFixtureTemplate("set-within-lower-scope.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
interpreter.render(template);
assertThat(globalContext).isEmpty();
}
@Test
public void itPutsDeferredVariablesOnParentScopes() {
String template = getFixtureTemplate("set-within-lower-scope.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
interpreter.render(template);
assertThat(localContext).containsKey("varSetInside");
Object varSetInside = localContext.get("varSetInside");
assertThat(varSetInside).isInstanceOf(DeferredValue.class);
DeferredValue varSetInsideDeferred = (DeferredValue) varSetInside;
assertThat(varSetInsideDeferred.getOriginalValue()).isEqualTo("inside first scope");
}
@Test
public void puttingDeferredVariablesOnParentScopesDoesNotBreakSetTag() {
String template = getFixtureTemplate("set-within-lower-scope-twice.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
String output = interpreter.render(template);
assertThat(localContext).containsKey("varSetInside");
Object varSetInside = localContext.get("varSetInside");
assertThat(varSetInside).isInstanceOf(DeferredValue.class);
DeferredValue varSetInsideDeferred = (DeferredValue) varSetInside;
assertThat(varSetInsideDeferred.getOriginalValue()).isEqualTo("inside first scope");
HashMap<String, Object> deferredContext = DeferredValueUtils.getDeferredContextWithOriginalValues(
localContext
);
deferredContext.forEach(localContext::put);
String secondRender = interpreter.render(output);
assertThat(secondRender.trim())
.isEqualTo("inside first scopeinside first scope2".trim());
}
@Test
public void itMarksVariablesSetInDeferredBlockAsDeferred() {
String template = getFixtureTemplate("set-in-deferred.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
String output = interpreter.render(template);
Context context = localContext;
assertThat(localContext).containsKey("varSetInside");
Object varSetInside = localContext.get("varSetInside");
assertThat(varSetInside).isInstanceOf(DeferredValue.class);
assertThat(output).contains("{{ varSetInside }}");
assertThat(context.get("a")).isInstanceOf(DeferredValue.class);
assertThat(context.get("b")).isInstanceOf(DeferredValue.class);
assertThat(context.get("c")).isInstanceOf(DeferredValue.class);
}
@Test
public void itMarksVariablesUsedAsMapKeysAsDeferred() {
String template = getFixtureTemplate("deferred-map-access.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
localContext.put("deferredValue2", DeferredValue.instance("key"));
ImmutableMap<String, ImmutableMap<String, String>> map = ImmutableMap.of(
"map",
ImmutableMap.of("key", "value")
);
localContext.put("imported", map);
String output = interpreter.render(template);
assertThat(localContext).containsKey("deferredValue2");
Object deferredValue2 = localContext.get("deferredValue2");
localContext
.getDeferredNodes()
.forEach(
node -> DeferredValueUtils.findAndMarkDeferredProperties(localContext, node)
);
assertThat(deferredValue2).isInstanceOf(DeferredValue.class);
assertThat(output)
.contains("{% set varSetInside = imported.map[deferredValue2.nonexistentprop] %}");
}

Writing my tests in this manner forces everything necessary for the test to be defined within the Jinjava template string, and also forces the assertions to be visible in the output, which makes it clear to demonstrate the impact.

I'll sometimes add these .expected.expected.jinja or it*SecondPass() tests when the initial output from eager execution isn't immediately obvious to be the correct answer.

In this case, it isn't terribly necessary. But for example with this test input:

{% for __ignored__ in [0] %}

{% set foo = deferred %}
{% endfor %}


{% set foo = deferred %}


{% for __ignored__ in [0] %}
{% if deferred %}
{{ foo }}
{% set foo = 'second' %}
{% endif %}
{{ foo }}
{% endfor %}
{{ foo }}


{% if deferred %}
{% set foo = 'second' %}
{% endif %}
{{ foo }}

The eager execution first-pass output is:

{% set my_list = ['a'] %}{% if deferred %}
{% set __macro_append_stuff_153654787_temp_variable_0__ %}
{% set __macro_foo_97643642_temp_variable_1__ %}
{% do my_list.append('b') %}
{% endset %}{{ __macro_foo_97643642_temp_variable_1__ }}
{% set __macro_foo_97643642_temp_variable_2__ %}
{% do my_list.append('c') %}
{% endset %}{{ __macro_foo_97643642_temp_variable_2__ }}
{% endset %}{{ __macro_append_stuff_153654787_temp_variable_0__ }}
{% endif %}

{% do my_list.append('d') %}


{{ my_list }}

The initial input is doing a roundabout way of creating a list and appending 'a', 'b', 'c', and 'd' to it, so it's useful to verify that the final output looks that way:

['a', 'b', 'c', 'd']

Defining these three strings in files which share a common naming scheme defines their relationship to each other, and ensures that the final output is always correct, even if there's some change in the eager execution code which slightly modifies the output of the first-phase

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't see how defining these strings statically in the class is any different than using external files, but you seem to feel strongly about this.

Copy link
Copy Markdown
Contributor Author

@jasmith-hs jasmith-hs left a comment

Choose a reason for hiding this comment

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

Hopefully this is helpful @boulter

@@ -0,0 +1,2 @@
L1: ['a']
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a good point, but separating the input and outputs in separate files lets me have very readable and consistent tests.
These three files:
-commits-variables-from-do-tag-when-partially-resolved.jinja
-commits-variables-from-do-tag-when-partially-resolved.expected.jinja
-commits-variables-from-do-tag-when-partially-resolved.expected.expected.jinja

Self-annotate their relationship to each other and describe what they're testing.

Compare this to how a god-test-file will become complex and inconsistent when able to specify any sort of setup and assertions:

@Test
public void itPreservesRandomness() {
String output = interpreter.render("{{ [1,2,3]|shuffle }}");
assertThat(output).isEqualTo("{{ [1,2,3]|shuffle }}");
assertThat(interpreter.getErrors()).isEmpty();
}
@Test
public void itDefersMacro() {
localContext.put("padding", 0);
localContext.put("added_padding", 10);
String deferredOutput = interpreter.render(
getFixtureTemplate("deferred-macro.jinja")
);
Object padding = localContext.get("padding");
assertThat(padding).isInstanceOf(DeferredValue.class);
assertThat(((DeferredValue) padding).getOriginalValue()).isEqualTo(10);
localContext.put("padding", ((DeferredValue) padding).getOriginalValue());
localContext.put("added_padding", 10);
// not deferred anymore
localContext.put("deferred", 5);
localContext.getGlobalMacro("inc_padding").setDeferred(false);
String output = interpreter.render(deferredOutput);
assertThat(output.replace("\n", "")).isEqualTo("0,10,15,25");
}
@Test
public void itDefersAllVariablesUsedInDeferredNode() {
String template = getFixtureTemplate("vars-in-deferred-node.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
String output = interpreter.render(template);
Object varInScope = localContext.get("varUsedInForScope");
assertThat(varInScope).isInstanceOf(DeferredValue.class);
DeferredValue varInScopeDeferred = (DeferredValue) varInScope;
assertThat(varInScopeDeferred.getOriginalValue()).isEqualTo("outside if statement");
HashMap<String, Object> deferredContext = DeferredValueUtils.getDeferredContextWithOriginalValues(
localContext
);
deferredContext.forEach(localContext::put);
String secondRender = interpreter.render(output);
assertThat(secondRender).isEqualTo("outside if statement entered if statement");
localContext.put("deferred", DeferredValue.instance());
localContext.put("resolved", "resolvedValue");
}
@Test
public void itDefersDependantVariables() {
String template = "";
template +=
"{% set resolved_variable = 'resolved' %} {% set deferred_variable = deferred + '-' + resolved_variable %}";
template += "{{ deferred_variable }}";
interpreter.render(template);
localContext.get("resolved_variable");
}
@Test
public void itDefersVariablesComparedAgainstDeferredVals() {
String template = "";
template += "{% set testVar = 'testvalue' %}";
template += "{% if deferred == testVar %} true {% else %} false {% endif %}";
interpreter.render(template);
Object varInScope = localContext.get("testVar");
assertThat(varInScope).isInstanceOf(DeferredValue.class);
DeferredValue varInScopeDeferred = (DeferredValue) varInScope;
assertThat(varInScopeDeferred.getOriginalValue()).isEqualTo("testvalue");
}
@Test
public void itDoesNotPutDeferredVariablesOnGlobalContext() {
String template = getFixtureTemplate("set-within-lower-scope.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
interpreter.render(template);
assertThat(globalContext).isEmpty();
}
@Test
public void itPutsDeferredVariablesOnParentScopes() {
String template = getFixtureTemplate("set-within-lower-scope.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
interpreter.render(template);
assertThat(localContext).containsKey("varSetInside");
Object varSetInside = localContext.get("varSetInside");
assertThat(varSetInside).isInstanceOf(DeferredValue.class);
DeferredValue varSetInsideDeferred = (DeferredValue) varSetInside;
assertThat(varSetInsideDeferred.getOriginalValue()).isEqualTo("inside first scope");
}
@Test
public void puttingDeferredVariablesOnParentScopesDoesNotBreakSetTag() {
String template = getFixtureTemplate("set-within-lower-scope-twice.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
String output = interpreter.render(template);
assertThat(localContext).containsKey("varSetInside");
Object varSetInside = localContext.get("varSetInside");
assertThat(varSetInside).isInstanceOf(DeferredValue.class);
DeferredValue varSetInsideDeferred = (DeferredValue) varSetInside;
assertThat(varSetInsideDeferred.getOriginalValue()).isEqualTo("inside first scope");
HashMap<String, Object> deferredContext = DeferredValueUtils.getDeferredContextWithOriginalValues(
localContext
);
deferredContext.forEach(localContext::put);
String secondRender = interpreter.render(output);
assertThat(secondRender.trim())
.isEqualTo("inside first scopeinside first scope2".trim());
}
@Test
public void itMarksVariablesSetInDeferredBlockAsDeferred() {
String template = getFixtureTemplate("set-in-deferred.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
String output = interpreter.render(template);
Context context = localContext;
assertThat(localContext).containsKey("varSetInside");
Object varSetInside = localContext.get("varSetInside");
assertThat(varSetInside).isInstanceOf(DeferredValue.class);
assertThat(output).contains("{{ varSetInside }}");
assertThat(context.get("a")).isInstanceOf(DeferredValue.class);
assertThat(context.get("b")).isInstanceOf(DeferredValue.class);
assertThat(context.get("c")).isInstanceOf(DeferredValue.class);
}
@Test
public void itMarksVariablesUsedAsMapKeysAsDeferred() {
String template = getFixtureTemplate("deferred-map-access.jinja");
localContext.put("deferredValue", DeferredValue.instance("resolved"));
localContext.put("deferredValue2", DeferredValue.instance("key"));
ImmutableMap<String, ImmutableMap<String, String>> map = ImmutableMap.of(
"map",
ImmutableMap.of("key", "value")
);
localContext.put("imported", map);
String output = interpreter.render(template);
assertThat(localContext).containsKey("deferredValue2");
Object deferredValue2 = localContext.get("deferredValue2");
localContext
.getDeferredNodes()
.forEach(
node -> DeferredValueUtils.findAndMarkDeferredProperties(localContext, node)
);
assertThat(deferredValue2).isInstanceOf(DeferredValue.class);
assertThat(output)
.contains("{% set varSetInside = imported.map[deferredValue2.nonexistentprop] %}");
}

Writing my tests in this manner forces everything necessary for the test to be defined within the Jinjava template string, and also forces the assertions to be visible in the output, which makes it clear to demonstrate the impact.

I'll sometimes add these .expected.expected.jinja or it*SecondPass() tests when the initial output from eager execution isn't immediately obvious to be the correct answer.

In this case, it isn't terribly necessary. But for example with this test input:

{% for __ignored__ in [0] %}

{% set foo = deferred %}
{% endfor %}


{% set foo = deferred %}


{% for __ignored__ in [0] %}
{% if deferred %}
{{ foo }}
{% set foo = 'second' %}
{% endif %}
{{ foo }}
{% endfor %}
{{ foo }}


{% if deferred %}
{% set foo = 'second' %}
{% endif %}
{{ foo }}

The eager execution first-pass output is:

{% set my_list = ['a'] %}{% if deferred %}
{% set __macro_append_stuff_153654787_temp_variable_0__ %}
{% set __macro_foo_97643642_temp_variable_1__ %}
{% do my_list.append('b') %}
{% endset %}{{ __macro_foo_97643642_temp_variable_1__ }}
{% set __macro_foo_97643642_temp_variable_2__ %}
{% do my_list.append('c') %}
{% endset %}{{ __macro_foo_97643642_temp_variable_2__ }}
{% endset %}{{ __macro_append_stuff_153654787_temp_variable_0__ }}
{% endif %}

{% do my_list.append('d') %}


{{ my_list }}

The initial input is doing a roundabout way of creating a list and appending 'a', 'b', 'c', and 'd' to it, so it's useful to verify that the final output looks that way:

['a', 'b', 'c', 'd']

Defining these three strings in files which share a common naming scheme defines their relationship to each other, and ensures that the final output is always correct, even if there's some change in the eager execution code which slightly modifies the output of the first-phase

!eagerExecutionResult.getResult().isFullyResolved() ||
interpreter.getContext().isDeferredExecutionMode()
) {
if (interpreter.getContext().isDeferredExecutionMode()) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This change means that we no longer care if the {% do %} block is fully evaluated or not, because we'll always commit the result. Unless in deferred execution mode, for example we wouldn't need to commit in this case:

{% set list1 = ['a'] %}
{% if deferred %}
{% do %}
{% set list2 = ['b'] %}
{% do list1.append(['c']) %}
{% do list2.append(deferred) %}
{% enddo %}
{% endif %}
L1: {{ list1 }}
L2: {{ list2 }}

entry -> ((OneTimeReconstructible) entry.getValue()).getOriginalValue()
)
)
.collect(Collectors.toMap(Entry::getKey, Entry::getValue))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Purpose: Allow storing DeferredValues in the SpeculativeBindings

Comment on lines -229 to -234
(
eagerExecutionResult.getResult().isFullyResolved() ||
eagerChildContextConfig.takeNewValue
) &&
!(entry.getValue() instanceof DeferredValue) &&
entry.getValue() != null
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved this filtering to line 249

entry.getKey(),
((DeferredValue) contextValue).getOriginalValue()
);
return new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), contextValue);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Purpose: Allow storing DeferredValues in the SpeculativeBindings

Comment on lines -376 to -378
if (e.getValue() instanceof DeferredValue) {
return ((DeferredValue) e.getValue()).getOriginalValue();
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Purpose: Allow storing DeferredValues in the SpeculativeBindings

.get(e.getKey())
.equals(getObjectOrHashCode(((DeferredValue) e.getValue()).getOriginalValue()))
) {
return ((DeferredValue) e.getValue()).getOriginalValue();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Purpose: Allow storing DeferredValues in the SpeculativeBindings

Comment on lines +273 to +275
if (value instanceof DeferredValue) {
value = ((DeferredValue) value).getOriginalValue();
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Purpose: Since we were getting the original value before storing these as SpeculativeBindings, we now need to convert them to the original values here instead so that we don't have the values reconstructed like:

{% set foo = {'originalValue': 'foo string'} %}

Comment on lines +753 to +760
.forEach(
(k, v) -> {
if (v instanceof DeferredValue) {
v = ((DeferredValue) v).getOriginalValue();
}
replace(interpreter.getContext(), k, v);
}
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Purpose: this is used to reset the state of the context to what it was before some logic was run, which we aren't committing.
For example if evaluating something within a deferred {% if bool %} block, we'll want to reset the bindings to what they used to be, this includes makes DeferredValues no longer be deferred so that the {% else %} block will evaluate properly. We'll then re-defer any values that got deferred during any if-elif-else blocks.
That logic already happens, I'm just explaining why we'd want to put the DeferredValue#getOriginalValue() onto the context here

Comment on lines +74 to +77
EagerReconstructionUtils.commitSpeculativeBindings(
interpreter,
eagerExecutionResult
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm migrating to this for other tags to have consistency. EagerDoTag is the only tag that actually needs a minor logic change (in that we have to filter out the DeferredValueShadow values).

We'd also want to filter out any DeferredValueShadow values for any of the other tags, but I can't think of any way that we would end up having them there so there aren't any new tests I can write for that. This doesn't change any of the functionality that we know about/have tested. If there does happen to be some situation where that could happen, then we'd filter those, which is desirable

Copy link
Copy Markdown
Contributor

@boulter boulter left a comment

Choose a reason for hiding this comment

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

I think I understand this now. Thanks.

@jasmith-hs jasmith-hs merged commit c1dbba8 into master Jun 16, 2023
@jasmith-hs jasmith-hs deleted the fix-new-values-in-do-tag-not-takes branch June 16, 2023 13:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants