Alpine: What is the Best Way to Ensure Idempotent Transformations?

Created on 13 Apr 2020  路  18Comments  路  Source: alpinejs/alpine

My project is using Alpine, Rails, and Turbolinks in Rails.

I'm using x-for to create img nodes for each image source in an array.

<template x-for="(imgSrc, index) in items" :key="index">
    <img :src="imgSrc" />
</template>

When I navigate to the page, the above code inserts image nodes for a carousel as expected. However, when navigating away, Turbolinks saves a copy of the transformed page to its cache. I then press the Back button and Turbolinks restores the page (and all previously inserted nodes) from the cache and the above code inserts a second set of image nodes again.

What is the best way to ensure idempotent transformations so that Alpine does not re-render nodes when they are already on the page from caching?

I have thought about using a rendered variable and then x-if on the template checking for the rendered variable, like below, but have not fully fleshed out how this might work.

<div x-data="{ rendered: false }" >
  <template x-if="!rendered" x-for="(imgSrc, index) in items" :key="index">
    <img :src="imgSrc" />
  </template>
</div>

What is the best way to ensure idempotent transformations so that Alpine does not re-render nodes when they are already on the page from caching?

bug

Most helpful comment

Hi all, I decided to go with removing any Alpine rendered nodes before the page is cached to fix this issue:

<template x-for="(carouselImage, index) in images" :key="index">
  <img
    class="object-contain h-full md:h-auto"
     :src="carouselImage"
     x-show.transition.opacity="activeImageIndex === index"
     :x-ref="`carouselImage-${index}`"
     x-on:turbolinks:before-cache.window="$refs[`carouselImage-${index}`].remove()"
  />
</template>

Here, for each rendered image node, I attach an x-on listener for turbolinks:before-cache, which then removes the element from the page before the cache. Then, when we navigate back to this page, it is restored from cache, and the images are rendered via Alpine with no problem.

I believe this is the best approach. It ensures the javascript sprinkles added via Alpine can run the same each time the page loads without worrying about it being cached or not.

All 18 comments

if i understand correctly, you are having issue when turbolinks page loaded from cache and you want to decide if alpine should re-render if it's from cache.

<template x-if="!rendered" x-data="{items: ['src1','src2','src3'], rendered: false}" x-init="rendered = document.documentElement.hasAttribute('data-turbolinks-preview')" x-for="(imgSrc, index) in items" :key="index">
    <img :src="imgSrc" />
</template>

i think you can check and assign "rendered" by checking if page is from cache.

Detecting When a Preview is Visible
Turbolinks adds a data-turbolinks-preview attribute to the <html> element when it displays a preview from cache. You can check for the presence of this attribute to selectively enable or disable behavior when a preview is visible. According to Turbolinks docs

if (document.documentElement.hasAttribute("data-turbolinks-preview")) {
  // Turbolinks is displaying a preview
}

Hope it helps.

@muzafferdede You have highlighted the crux of the problem now: though it isn't the turbolinks preview causing issues, it is the document being cached by turbolinks.

Alpine renders the nodes and then turbolinks caches that document with the rendered nodes by Alpine. I could perhaps destroy the rendered Alpine nodes before they are cached using the turbolinks:before-cache event.

document.addEventListener("turbolinks:before-cache", function() {
  // ...
})

Or in an Alpine-esque fashion, I could use some combination of x-on and .document or .window that listens for the turbolinks:before-cache event and then destroys the element at that time - something like:

<div x-ref="foo" x-on:turbolinks:before-cache.document="$refs.foo.remove()"></div>

Alternatively, I let those image nodes be cached by turbolinks and then add a check to not perform the additional Alpine rendering if those nodes already exist.

I guess another way to think about this issue, as well, is to say: I like that the elements being rendered are cached, no need for Alpine to render them again.

Hence, have a way to tell Alpine, do not execute this code again - the elements are on the page. This would be similar to a component updating in other JS frameworks.

Could you make a codepen or any sample to help us understand and have a look at it ? Just want to make sure if it's domain specific or alpine related issue.

Installing JavaScript Behavior
You may be used to installing JavaScript behavior in response to the window.onload, DOMContentLoaded, or jQuery ready events. With Turbolinks, these events will fire only in response to the initial page load, not after any subsequent page changes. We compare two strategies for connecting JavaScript behavior to the DOM below.

This makes me think that you could use

<template 
  x-if="!rendered" 
  x-data="{items: ['src1','src2','src3'], rendered: false}"
  x-on:load.window="rendered = true" 
  x-for="(imgSrc, index) in items" 
  :key="index"
>
    <img :src="imgSrc" />
</template>

since turbolinks will only trigger window.load initial page load.

Suboptimal solution but does it work okay if you disable the cache?
<meta name="turbolinks-cache-control" content="no-cache">

@muzafferdede x-on:load.window="rendered = true", which seems promising, unfortunately does not work. Here is a code sample:

<div
    class="carousel"
    x-data="{
      activeImageIndex: 0,
      images: <%= @product_image_urls %>
    }"
  >
    <div class="h-64 md:flex-1 flex justify-center items-center overflow-hidden bg-white rounded shadow-sm object-contain border border-gray-200 mb-2">
      <template x-for="(carouselImage, index) in images" :key="index">
        <img
          class="object-contain h-full md:h-auto"
          :src="carouselImage"
          x-show.transition.opacity="activeImageIndex === index"
          data-turbolinks="false"
        />
      </template>
    </div>
</div>

@SimoTod Yes disabling works: as a quick fix I disabled the cache for that page and the Alpine code renders as expected.

I think the turbolinks-before-cache approach is the way to go. It can go directly in Alpine depending on whether or not it supports turbolinks in its core which was already mentioned in another issue #319

@bep Have you met this issue before? Do you have a workaround for it?
(Bj酶rn uses turbolinks + alpine quite a lot so he might have a solution for it)

@bep Have you met this issue before?

I have not read this entire thread, but looking at the first code snippet:

<template x-for="(imgSrc, index) in items" :key="index">
    <img :src="imgSrc" />
</template>

This is undercommunicated in the docs, but the :key needs to be there (or: maybe the code fall back to using the item as key?), and it must be a _real key_ (as in: not index). This is especially true with Turbolinks.

maybe the code fall back to using the item as key

I believe this is the behaviour

This is the code that is rendered in error when I go back in the browser to the page (so it shows the cached document). You can see that the x-data only has 2 image sources in the images array. However, the cached document already has those two image nodes, so Alpine runs the code again and we can see there are now 4 images nodes on the document, in error.

<div class="carousel" x-data="{
      activeImageIndex: 0,
      images: ["http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBIUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--5278ade5fcc8ab4c117d53dabb1f393c8f09855b/anchor_mat.jpeg", "http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--72eaf9167739b5bd5b0c7c08e6ae4c2dd5ad19d4/anchor-doormat-home.jpg"]
    }">
    <div class="h-64 md:flex-1 flex justify-center items-center overflow-hidden bg-white rounded shadow-sm object-contain border border-gray-200 mb-2">
      <template x-for="(carouselImage, index) in images" :key="index">
        <img class="object-contain h-full md:h-auto" :src="carouselImage" x-show.transition.opacity="activeImageIndex === index">
      </template>


        <img class="object-contain h-full md:h-auto" :src="carouselImage" x-show.transition.opacity="activeImageIndex === index" src="http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBIUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--5278ade5fcc8ab4c117d53dabb1f393c8f09855b/anchor_mat.jpeg">

        <img class="object-contain h-full md:h-auto" :src="carouselImage" x-show.transition.opacity="activeImageIndex === index" src="http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--72eaf9167739b5bd5b0c7c08e6ae4c2dd5ad19d4/anchor-doormat-home.jpg" style="display: none;">
      <img class="object-contain h-full md:h-auto" :src="carouselImage" x-show.transition.opacity="activeImageIndex === index" src="http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBIUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--5278ade5fcc8ab4c117d53dabb1f393c8f09855b/anchor_mat.jpeg">

        <img class="object-contain h-full md:h-auto" :src="carouselImage" x-show.transition.opacity="activeImageIndex === index" src="http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--72eaf9167739b5bd5b0c7c08e6ae4c2dd5ad19d4/anchor-doormat-home.jpg" style="display: none;">
      </div>
    <div class="flex flex-wrap md:h-32">
      <template x-for="(thumbnail, index) in images" :key="index">
        <img class="h-12 mx-1 bg-white rounded object-contain cursor-pointer border" :src="thumbnail" :class="{
            'border-gray-600': activeImageIndex === index,
            'border-gray-200': activeImageIndex !== index,
          }" x-on:click="activeImageIndex = index">
      </template>

        <img class="h-12 mx-1 bg-white rounded object-contain cursor-pointer border border-gray-600" :src="thumbnail" :class="{
            'border-gray-600': activeImageIndex === index,
            'border-gray-200': activeImageIndex !== index,
          }" x-on:click="activeImageIndex = index" src="http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBIUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--5278ade5fcc8ab4c117d53dabb1f393c8f09855b/anchor_mat.jpeg">

        <img class="h-12 mx-1 bg-white rounded object-contain cursor-pointer border border-gray-200" :src="thumbnail" :class="{
            'border-gray-600': activeImageIndex === index,
            'border-gray-200': activeImageIndex !== index,
          }" x-on:click="activeImageIndex = index" src="http://example.test:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--72eaf9167739b5bd5b0c7c08e6ae4c2dd5ad19d4/anchor-doormat-home.jpg">
      </div>
  </div>

And Alpine.js console logs the following error:

Uncaught ReferenceError: carouselImage is not defined

@bep Changing the key to something more unique like key + 'carousel' or using the item as the key did not change the behaviour.

OK, then I don't know -- that said, I suspect that #364 will to some extent improve the Turbolinks situation.

maybe the code fall back to using the item as key

I believe this is the behaviour

It's uses the index as a default key.

Hi all, just labelling this issue for an easy eye catch when we come to look at this in the future. Has anyone else had any thoughts or opinions on this, would be great to here some!

Hi all, I decided to go with removing any Alpine rendered nodes before the page is cached to fix this issue:

<template x-for="(carouselImage, index) in images" :key="index">
  <img
    class="object-contain h-full md:h-auto"
     :src="carouselImage"
     x-show.transition.opacity="activeImageIndex === index"
     :x-ref="`carouselImage-${index}`"
     x-on:turbolinks:before-cache.window="$refs[`carouselImage-${index}`].remove()"
  />
</template>

Here, for each rendered image node, I attach an x-on listener for turbolinks:before-cache, which then removes the element from the page before the cache. Then, when we navigate back to this page, it is restored from cache, and the images are rendered via Alpine with no problem.

I believe this is the best approach. It ensures the javascript sprinkles added via Alpine can run the same each time the page loads without worrying about it being cached or not.

Nice one @stevenpslade thanks for sharing your solution

Closing since we've got a separate project to deal with Turbolinks integration: https://github.com/alpinejs/alpine-turbolinks-adapter

If you're having turbolink issues after adding the adapter, feel free to raise issues on the adapter repo https://github.com/alpinejs/alpine-turbolinks-adapter/issues.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

bep picture bep  路  4Comments

andruu picture andruu  路  3Comments

dkuku picture dkuku  路  5Comments

maxsite picture maxsite  路  4Comments

BernhardBaumrock picture BernhardBaumrock  路  3Comments