Skip to content

Instantly share code, notes, and snippets.

@peerreynders
Last active February 22, 2022 16:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save peerreynders/db2f446cac02378e99bf826a6270df5e to your computer and use it in GitHub Desktop.
Save peerreynders/db2f446cac02378e99bf826a6270df5e to your computer and use it in GitHub Desktop.
Experiment: Astro with Communicating Islands

Experiment: Astro with Communicating Islands

Note: There is an official example with-nanostores that lets two islands communicate via nanostores.


Caution: At this point it is unclear whether this technique interferes with the islands autonomous hydration process. On the surface this seems to work but it's not clear what potential drawbacks there may be.

The experiment was performed on a "Starter Kit" installation with a Preact renderer.

The experiment has two separate islands (with separate astro-roots) running off of the same "store".

---
// file: src/pages/index.astro

import { makeState, bootData } from '../app/counter-store.js';
import Counter from '../components/Counter.jsx';
import DisplayCount from '../components/DisplayCount.jsx';

import BaseLayout from '../layouts/BaseLayout.astro'
import Filler from '../components/Filler.astro';
import PageBootData from '../components/PageBootData.astro';

// Assemble the initial state at build time
// by any means necessary (e.g. fetch)
//
const count = 10;
bootData.initialize(makeState(count));

// Ensure initial state is available
await bootData.dataExport;
---
<BaseLayout>
  <main>
    <DisplayCount client:load />
    <Filler />
    <Counter client:visible />
    <PageBootData />
  </main>
</BaseLayout>

File index.astro: This outlines the overall concept. counter-store is the entity that both Counter and DisplayCount are (implicitly) connected to.

  • The frontmatter of index.astro is responsible for aquiring the necesssary state data at build time. Here makeState is used to shape the data for the store, before the store is initialized with it. bootData.initialize causes the bootData.dataExport promise to resolve to the serialized data.
  • This causes the PageBootData astro component to render state data into the page.
  • Both the DisplayCount and Counter are Preact components which each has it's own astro-root as they are both separated by the Filler astro component.

The rendered HTML looks something like

<main>
  <astro-root uid="Z9PpVt">
    <div class="counter">
      <pre>10</pre>
    </div>
  </astro-root>
  <div class="filler">
    <img width="60" height="80" src="/assets/logo.svg" alt="Astro logo">
    <h1>Welcome to <a href="https://astro.build/">Astro</a></h1>
  </div>
  <astro-root uid="E3x84">
    <div class="counter">
      <button>-</button>
      <pre>10</pre>
      <button>+</button>
    </div>
  </astro-root>
  <script id="page-boot-data" type="application/json">{"count":10}</script>
</main>

Astro component PageBootData

---
// file: src/components/PageBootData.astro

import { bootData } from '../app/counter-store.js';

const data = await bootData.dataExport;
const scriptHtml = `<script id="${bootData.id}" type="application/json">${data}</script>`;
---
{ scriptHtml }
  • This component simply "awaits" for the serialized initial data to become available. Then it produces a script block to embed the serialized data in the HTML. In the browser this block will be read to initialize the counter-store state.

Preact component DisplayCount:

// file: src/components/DisplayCount.jsx
//

import { useCounter } from '../app/counter-store.js';

export default function DisplayCount() {
  const count = useCounter();

  return (
    <div class="counter">
      <pre>{count}</pre>
    </div>
  );
}
  • Simply displays the current count found inside of counter-store.

Preact Component Counter:

// file: src/components/Counter.jsx
//
import { increment, decrement, useCounter } from '../app/counter-store.js';

export default function Counter() {
  const count = useCounter();

  return (
    <div class="counter">
      <button onClick={decrement}>-</button>
      <pre>{count}</pre>
      <button onClick={increment}>+</button>
    </div>
  );
}
  • In addition to displaying the current count also exposes increment and decrement buttons.

Module prime-store:

// file: src/app/prime-store.js

function hydrateFrom(elementId) {
  const element = document.getElementById(elementId);
  return JSON.parse(element.text); 
}

function makeBootData(elementId, setStore) {
  let exportData;

  return {
    id: elementId,
    dataExport: new Promise(resolve => exportData = resolve),
    initialize(initialState) {
      const serialized = JSON.stringify(initialState);
      setStore(initialState);
      if (exportData) exportData(serialized);
    }
  };
}

function primeStore(dataId, initialized, notify) {
  let data;
  const isBrowser = typeof window === 'object';
  const initializeStore = state => {
    data = state;
    initialized(data);
  };
  const bootData = isBrowser ? {} : makeBootData(dataId, initializeStore);
  if (isBrowser) initializeStore(hydrateFrom(dataId));

  return {
    update,
    bootData
  };

  function update(transform) {
    data = transform(data);

    notify(data);
  }
}

export {
  primeStore
};
  • primeStore
    • server side a bootData object is created with makeBootData. id is initialized with dataId to identify the script block that holds the relevant serialized state. dataExport is a promise that resolves to the serialized state once initialize has been invoked with the initial state.
    • browser side hydrateFrom is used to deserialize the state from the contents of the script block identified by dataId
    • The returned object holds an update function and a bootData object (which is empty browser side).
      • update updates the state with the passed transform (after which notify is invoked; initialized is invoked instead of notify when the store is initialized with its initial state).

Module counter-store:

// file: src/app/counter-store.js
import { useEffect, useState } from 'preact/hooks';
import { primeStore } from './prime-store.js';

// store customizations
//
let initialCount = 0;
const subscribed = new Set();
const DATA_ID = 'page-boot-data';

function initialized(state){
  initialCount = state.count;
}

function subscribe(cb) {
  subscribed.add(cb);
  const unsubscribe = () => subscribed.delete(cb);
  return unsubscribe;
}

function notify(state) {
  for (const cb of subscribed)
    cb(state.count);
}

const store = primeStore(DATA_ID, initialized, notify);
const bootData = store.bootData;

function makeState(count) {
  return {
    count
  };
}

// hook
//
function useCounter() {
  const [count, setCount] = useState(initialCount);

  useEffect(() => subscribe(
    value => setCount(value)
  ), [setCount]);

  return count;
}

const incCount = (state) => (++state.count, state);
const decCount = (state) => (--state.count, state);

const increment = () => store.update(incCount);
const decrement = () => store.update(decCount);

export {
  makeState,
  bootData,
  useCounter,
  increment,
  decrement,
};
  • counter-store exposes the store prepared by primeStore via a useCounter hook for Preact components.
    • page-boot-data is the element ID used to render state server side and hydrate browser side.
    • subscribers are notified with the count property when the store's update is invoked.
    • the initialCount is cached as a primitive value when the store initializes in order to have a "cheap" initialization value in the useCounter hook.
    • makeState shapes the primitive count value into an object to initialize the store.
    • useCounter will subscribe any component that uses it for updates.
    • increment and decrement "actions" are also made available (for Counter component).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment