Nested cart lines for addons now works šŸ”„

The purpose of this post is to contain information, usecases, examples and discussions of the newly released Nested cart lines.

It’s still very early on, and I’m just testing the new things myself.

Feel free to add usecases and findings as a comment - I’ll link relevant stuff up here. Let’s deep dive it collectively!


Test adding nested lines from frontend:

Adding parent and child together
const formData = {
  'items': [
    {
      'id': 48838251381083,
      'quantity': 1
    },
    {
      'id': 48838251053403,
      'parent_id': 48838251381083,
      'quantity': 1
    }]
};

fetch(window.Shopify.routes.root + 'cart/add.js', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(formData)
})
  .then(response => {
    return response.json();
  })
  .catch((error) => {
    console.error('Error:', error);
  });

This will add both variants to the cart with the following new props on the child line (48838251053403):

"parent_relationship": {
  "parent_key":   "48838251381083:754a9a2be65aec51642aabac38291aa3"
},
"instructions": {
  "can_remove": true,
  "can_update_quantity": true
}
Adding parent and child separately
async function addParentThenChild() {
  // Add parent item
  await fetch(window.Shopify.routes.root + 'cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      items: [
        { id: 48838251381083, quantity: 1 }
      ]
    })
  });

  // Add child item after parent
  await fetch(window.Shopify.routes.root + 'cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      items: [
        { id: 48838251053403, parent_id: 48838251381083, quantity: 1 }
      ]
    })
  });
}

addParentThenChild();

This will successfully add the parent line but throw a 422 error for the child {"status":422,"message":"Parent line does not match any existing or added lines","description":"Parent line does not match any existing or added lines"}

To add the child later, you’d have to get the line key of the parent and use parent_line_key instead of the parent_id.

const formData = {
  'items': [
    {
      'id': 48838251053403,
      'parent_line_key': '48838251381083:754a9a2be65aec51642aabac38291aa3',
      'quantity': 1
    }]
};
Adding via liquid and FormData
{%- form 'product', product -%}
  <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
  <input type="hidden" name="items[0][id]" value="55041416233285">
  <input type="hidden" name="items[0][quantity]" value="1">
  <input type="hidden" name="items[0][parent_id]" value="{{ product.selected_or_first_available_variant.id }}"> 
  <button type="submit" class="button w-full">Add to cart</button>
{%- endform -%}
Adding Cart API using FormData
{% form 'product', product, id: 'product-test-form' %}
  <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
  <input type="hidden" name="items[0][id]" value="55041416233285">
  <input type="hidden" name="items[0][quantity]" value="1">
  <input type="hidden" name="items[0][parent_id]" value="{{ product.selected_or_first_available_variant.id }}"> 
  <button type="submit" class="button w-full" id="add-to-cart-btn">Add to cart</button>
{% endform %}

{% javascript %}
  document.addEventListener('DOMContentLoaded', function() {
    const form = document.querySelector('#product-test-form');

    form.addEventListener('submit', async function(e) {
      e.preventDefault();
      
      try {
        const formData = new FormData(form);
        
        const response = await fetch(`${Shopify.routes.root}cart/add.js`, {
          method: 'POST',
          body: formData,
          headers: {
            'X-Requested-With': 'XMLHttpRequest'
          }
        });
        
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        
        const result = await response.json();
        console.log(result);
      } catch (error) {
        console.error('Error:', error);
      }
    });
  });
{% endjavascript %}

Other findings

Cart removal
If you remove the child line, it just deletes that part of the ā€œgroupā€.

If you remove the parent line, it will also remove the child line. However, there seems to be no indication of this, in the API responses


Post order management
Functions like ā€œBuy againā€ and returns handles nested lines as separate lines. So ā€œBuy againā€ adds them separately (not nested anymore).

Thanks for testing this @Teun

Examples of how to visually group it in the cart

Thanks to @Gabrielle_Rea we found something that can work

Link to comment

Horizon implementation

Seems like Horizon has it implemented using pure CSS depending on cart items order always having parent>child.

Link to liquid part (setting the class)
Link to CSS

Seems to also work when adding separately: https://x.com/Curzey1/status/1955947166066684160

Usecases

I think the usecases stated in the docs actually represent it very well, but will leave this open in case someone finds some cool creative ones.

From docs

This enables use cases like product add-ons (warranties, protection plans, service fees) that attach to a product in cart. For example, you might attach an extended warranty to a TV. Unlike regular cart lines, nested cart lines are always ordered after their parent line, and are removed from cart if parent is removed.

Generally I just think it’s great to have usescases like this abstract from bundles since the previous ā€œfixā€ was to use cart transforms to merge (bundle) items.

Post frontend

Checkout

Order


I guess I will change the structure of this post, when we add more usecases, examples or other stuff.

4 Likes

6 Likes

Makes sense with this one as you could have multiple lines with the same product (due to line item properties or selling plans).

Be interesting to see how to show this in Liquid.

1 Like

Not sure how it’s expected to actually use these props in the cart with liquid.
Anyone tried that?

{{ line_item.parent_relationship }} {% # -> ParentRelationshipDrop %}
{{ line_item.parent_relationship | json }} {% # -> {"error":"json not allowed for this object"} %}
{{ line_item.parent_relationship.parent_key }} {% # -> empty %}
{{ line_item.parent_relationship.parent_key | json }} {% # -> null %}

Also - maybe I’m too tried right now - but wouldn’t we kinda need some indentifier on the parent element, to prevent something like this?

{%- for line_item in cart.items -%}              
  {%- if line_item.parent_relationship -%}
    {%- continue -%}
  {%- endif -%}
  
   <h1>{{ line_item.title }}</h1>
    
   {%- for nested_item in cart.items -%}
     {%- if nested_item.parent_relationship.parent_key == line_item.key -%}
        <h2>{{ nested_item.title }}</h1>
     {%- endif -%}
   {%- endfor -%}
{%- endfor -%}

Seems like the Liquid API/docs is still missing something? https://shopify.dev/docs/api/liquid/objects/line_item


Edit

@Gabrielle_Rea found it by being philosophical with the .dev assistant. Liquid uses line_item.parent_relationship.parent.key (_ → .).

What is inside parent relationship

Still seems kinda weird as cart.json has another structure

# cart.json
"parent_relationship": {
   "parent_key": "55104547422533:33940312955d16b673f73b17aa033496"
},

But this makes the following work - still not ideal?

{%- for line_item in cart.items -%}              
  {%- if line_item.parent_relationship -%}
    {%- continue -%}
  {%- endif -%}
  
   <h1>{{ line_item.title }}</h1>
    
   {%- for nested_item in cart.items -%}
     {%- if nested_item.parent_relationship.parent.key == line_item.key -%}
        <h2>{{ nested_item.title }}</h1>
     {%- endif -%}
   {%- endfor -%}
{%- endfor -%}
1 Like

Yeah thats how we’d normally do it with the multi-level looping, not ideal!

Just wondering what the options are for returning. Is it possible for buyers to only return specific child items? Did anything change here, or are these items still seen as separate items in the self-return center?

2 Likes

Just wondering what the options are for returning. Is it possible for buyers to only return specific child items? Did anything change here, or are these items still seen as separate items in the self-return center?

This is also an interesting one, that I havn’t yet got to test.

Would appreciate if you’re testing, that you put the answer in here. Will add it to the main post when we know whats up.

Some things I noticed with a quick test:

  • Every item can be returned separately. When excluding the ā€˜addon products’ from the return center, only the main product can be returned. This is (probably) fine for our case, since the add-ons we use are always free.
  • When clicking ā€˜Buy again’ from the account page, it adds all items to the cart as separate items. The parent relation is no longer used. That could be confusion for the customer, since the account page does show the parent relation.
2 Likes

Thanks for testing! Will add to the original post with a link.

  • When clicking ā€˜Buy again’ from the account page, it adds all items to the cart as separate items. The parent relation is no longer used. That could be confusion for the customer, since the account page does show the parent relation.

Yeah, this is unfortunately expected, as ā€œBuy againā€ have always ignored custom stuff like bundles and line attributes.

Thanks @curzey, this is awesome! I literally saw the new changelog just in time for something I’m working on that’s going to benefit tremendously from this.

After playing with it for a bit, there are two questions/pieces of feedback I’d like to relay.

First, I notice that if I have two identical items that are added as children to two different items in my cart, both child items are given the same line key. This seems like it could potentially have some nasty downstream effects.

For one, it means that if I need to update or remove one of the items, I don’t actually have a way of specifying which I want to update – the API only allows me (as far as I’m aware?) to specify the line key, and with this feature the line key is no longer unique.

Besides that, there are places in my code where I may be assuming that the line key is unique (i.e. there should be no two line items that share the same key). If this is no longer a safe assumption, then I may need to review my usage of line keys in my codebase to make sure no bugs have been introduced as a result of this change.

Ideally, I think the parent_id/parent_line_key should be used in calculating the line key, so that the line key remains useful as a way to uniquely identify line items.

Second, I’m curious if there’s a way to accomplish the ā€œAdding parent and child togetherā€ use case in a way that’s compatible with the possibility of the same variant already being in the cart as a different line item (as alluded to by @Luke). It’d be really nice to be able to add parent/child items atomically in a way that doesn’t break in the case where multiple lines exist with the same variant ID.

Thanks again!

Edit: Now that I look closer I see that you’re not actually a Shopify employee as I initially thought. Sorry for the confusion! Does anyone know the best way to relay this feedback to the proper stakeholders at Shopify?

1 Like

Hey @rpbiwer - some good points.

Maybe I misunderstood; but it is indeed possible to add the parent and child together in one request (ref).

I asked a bit on X around moving the identifier to the parent, just like bundles. Which might remove some of the walls you’re running into: https://x.com/Curzey1/status/1955579340042936564

That being said; I think we need to work with nested lines as the simple solution. Again, their examples is warranties, gift wrapping etc. Need more flexibility? You probably still will need Plus and use cartTransform and misuse bundles :sweat_smile:


I hoped some Shopifolk would have joined this thread, tbh

My bad, I thought you were Shopifolk :smile: I agree, I’d really like someone from Shopify to see this thread.

Re: ā€œadding child and parent togetherā€: I haven’t tested this, but I’d expect it to fail in the case where the cart already contains an item with ID 48838251381083 and some line properties to differentiate it from the 48838251381083 being added. In such a case, after applying the add operation detailed in your example, there will be three line items:

  1. Variant 48838251381083 with some line properties
  2. Variant 48838251381083 with no line properties
  3. Variant 48838251053403 which is a child of… which line, exactly?
1 Like

Haha - I’m not :sweat_smile:

Well that’s interesting. Will definitely have to run some tests on that later today / tomorrow.

Running the following:

const formData = {
  'items': [
    {
      'id': 48838251381083,
      'quantity': 1
    },
    {
      'id': 48838251053403,
      'parent_id': 48838251381083,
      'quantity': 1
    }]
};

fetch(window.Shopify.routes.root + 'cart/add.js', {
  //... removed the rest to save keystrokes

and then

const formData = {
  'items': [
    {
      'id': 48838251381083,
      'quantity': 1,
      'properties': { 'foo': 'bar' }
    }]
};

fetch(window.Shopify.routes.root + 'cart/add.js', {
  //... removed the rest to save keystrokes

Gave a result of three line items, which I guess now as I write this is really expected, as your point 3 (ā€œVariant 48838251053403 which is a child of… which line, exactly?ā€) obviously makes sense is a child of the variant it was added with. Due to the line key connection and not just variant.

{
  "item_count": 3,
  "items": [
    {
      "id": 48838251381083,
      "quantity": 1,
      "key": "48838251381083:cb3848686c62b1f20be9aebc07c1efa0",
      "properties": { "foo": "bar" },
    },
    {
      "id": 48838251381083,
      "quantity": 1,
      "key": "48838251381083:754a9a2be65aec51642aabac38291aa3",
      "properties": {},
    },
    {
      "id": 48838251053403,
      "quantity": 1,
      "key": "48838251053403:167a5eb06243a52ba4a252cd8d465337",
      "properties": {},
      "parent_relationship": { "parent_key": "48838251381083:754a9a2be65aec51642aabac38291aa3" },
      "instructions": { "can_remove": true, "can_update_quantity": true }
    }
  ]
}

As per my examples in the main thread, it’s also stated that adding a child line separately later will require matching the line instead of variant ID.


I also tried reversing the requests where the property populated variant is added first. Gave the same results.


First adding the property variant, then the no-property variant and then then child variant is redundant, as that would require a ref to the line key anyways.


Did this actually even answer your question? :sweat_smile:

1 Like

Thanks for running that test – it does answer my question! One hopes that that behavior is well defined and intentional as opposed to being an accident that’s subject to change in the future :man_shrugging:

My biggest concern is still the non-uniqueness of line keys. I’m still hoping that someone from Shopify can chime in on that!

1 Like

@Luke Looks like we should just visually fix it with CSS and no need to map with liquid.

Added the Horizon implementation as a note the the main post.

seems like Draft Orders API isn’t supporting nested line items? would be nice to save a nested cart as a draft order :confused:

2 Likes

It’s funny, because I was just testing something around draft orders and bundled products via Functions MergeOperation. In that case draft orders (submit as or duplicate an order) removes all line properties, but keeps the reference to ā€œparentā€.

However, doing the exact same but with nested lines - instead of bundles - line properties is preserved but the reference to parent is omitted, they’re indeed not nested anymore.

Could kinda fix the latter with an extra line prop referencing the hierarchy, but wont work for everything.

1 Like

I’ve been having a conversation with Plus support about this limitation, and the final word seems to be that the non-uniqueness of line keys is known and intentional. Their official recommendation is to use the line parameter of the change.js Ajax API endpoint in order to target child line items. This, unfortunately, means that child lines cannot be updated or removed using the update.js endpoint. Per Plus support:

There are a few known limitations regarding the line property, which the devs are aware of. One of these limitations being you cannot remove a child element with the Ajax API.

There’s also the obvious limitation of using the line field which is that you don’t actually know which line you’re editing – if the user has made edits to the cart in another tab, then congrats, you’ve corrupted your cart!

So, unfortunately it seems that they have no intention of fixing this error. To get around it, I’ve had to add a hidden random number line property to every child line item in order to ensure it gets a unique line key. Not ideal, and I’m disappointed in Shopify’s answer, but it works. :person_shrugging:

3 Likes

Hi @curzey
Do you know how the nest cart lines showing in the cart page or cart drawer?