Shopify / slate

Slate is a toolkit for developing Shopify themes. It's designed to assist your workflow and speed up the process of developing, testing, and deploying themes.
https://shopify.github.io/slate
MIT License
1.28k stars 365 forks source link

Singleton Pattern with Webpack #1009

Open 1aurabrown opened 5 years ago

1aurabrown commented 5 years ago

This repo is currently on low maintenance. See README for details

Problem

Hello, I am developing a theme which involves a mini-cart, which I understand has been intentionally excluded from the starter theme. In the past I've used https://cartjs.org, this library is pretty old and I've already had to make modifications to it to get it working correctly with the previous version of Slate, so this time I wanted to roll my own mini-cart.

I have both a Cart section and a Product section, both of which are present within the Product page template, and each of these sections may trigger changes to the cart (Product: add to cart, Cart: remove from cart). My issue is getting the Cart section to react to changes triggered by the Product section. I have made a wrapper around shopify/theme-cart, which both sections may import. With the addition of https://www.npmjs.com/package/event-emitter, the cart-api-wrapper emits a change event after any successful request to the cart API. The Product section should be able to call an addToCart method in this object, which will trigger an event on success to which the Cart section is subscribed, triggering re-render within the Cart section. However because of the setup with Webpack in Slate, it seems that the cart-api-wrapper object imported into the Product section js is a different instance from the one imported in the Cart section js. The cart-api-wrapper should be a singleton, however the instance that the Cart section is subscribed to is different than the instance that the Product section interacts with. Changes originating from the Cart section itself do trickle down to the re-render event listener within the Cart section, as expected.

It appears that that within the Product template entry point the cart-wrapper module is included twice (once from the Cart section which is present on every page, once from the Product section). I'm not super familiar with Webpack but from what I understand, this is not supposed to be happening. Has anyone successfully implemented a pattern like this, or do you have other suggestions for implementing the mini-cart in such a way that it reacts to cart api events originating from any other section?

Replication steps

Create a module that exports a singleton object, it should contain a method which emits an event. Import the singleton object in two different sections present on the same page, make one section subscribe to the event emitted by the singleton, make the other section call the method in the singleton which emits said event.

justinmetros commented 5 years ago

Are you able to provide a simplified repro? Or comment with the most simplified code example creating this error?

mateowinn commented 5 years ago

Hey @justinmetros - I've run into this issue, as well, and I can provide you with some simple repro steps.

  1. Create a js module that will be commonly imported by both theme.js and product.js. src/scripts/sharedImport.js console.log('I am a shared import!');

  2. Import sharedImport.js into both files. import '../sharedImport.js';

  3. Deploy and navigate to the product page. See that "I am a shared import!" is logged twice.

Let me know if there's any confusion in my instructions. Thanks!

tshamz commented 5 years ago

I ran into this same problem while trying to implement pub/sub in my theme. Two different threads for theme.js and <template>.js were created and subscribers in one context couldn't listen for published messages in the other. The way I solved this was by extending my webpack config in slate.config.js by adding the following:

'webpack.extend': {
    optimization: {
      runtimeChunk: 'single',
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/](pubsub-js)[\\/]/,  // update with your module name
            name: 'vendor',
            chunks: 'all',
            enforce: true,
          }
        },
      },
    },
}

I can't remember exactly why this works, but I seem to remember it doing something like pulling out the shared dependency into its own file and having the two entry points import that single instance into their context.

Here's a link to the documentation on splitChunks and runtimeChunk, here's a link to more information about splitChunks, and here's a link to an example.

nboliver commented 5 years ago

@1aurabrown Not sure if this helps, but our solution to the mini cart uses some helper functions to track the multiple carts:

/**
 * Build an array of cart instances
 *
 * @param {Object} instance
 */
function addInstance(instance) {
  if (!window.cartInstances) {
    // Create global because of codesplitting between theme and cart
    window.cartInstances = [];
  }

  window.cartInstances.push(instance);
}

/**
 * Dispatch an update to all cart instances
 *
 * @param {*} updatedData Optional data passed when update is dispatched
 *  Example: The json of the cart or item that was just added
 */
function dispatchUpdate(updatedData) {
  if (!window.cartInstances) {
    return;
  }

  window.cartInstances.forEach(instance => {
    instance.refreshContent(updatedData);
  });
}

export { addInstance, dispatchUpdate };

Then we have a Cart class that calls addInstance when it's instantiated and a MiniCart class that extends the Cart class:

export default class Cart {
  /**
   * Creates an instance of Cart
   *
   * @param {HTMLElement} el The container element
   */
  constructor(el) {
    this.el = el;

    cartManager.addInstance(this);
  }

  /**
   * Handles refreshing cart content
   * Can be accessed in child classes for specific logic per child
   *
   * @param {*} updatedData Optional data passed when update is dispatched
   *  Example: The json of the cart or item that was just added
   * @returns {Object} jqXHR object
   */
  refreshContent(updatedData) {
    // Do your cart updating here
  }
  ...
}

Finally, wherever you have AJAX that is manipulating the cart, call dispatchUpdate (example is a method in our CartItem class):

  _removeItem(event) {
    event.preventDefault();

    const id = this._getItemId(event.currentTarget);

    cartApi.remove(id).done(cart => cartManager.dispatchUpdate(cart));
  }