PAD + islands

Three frameworks, one cart

A React shelf, a Vue summary and a Svelte badge share one store; adding posts to PAD, which keeps the cart in the session and answers it to all three.

padSession$padExpose store()post()
Live
The badge is Svelte:
🛒 0

How it is built

PAD, on the server the islands, built by Vite

<div class="shared-top">
  <span class="muted small">The badge is Svelte:</span>
  <div data-island="CartBadge" data-props="{^badge}"><span class="cart-badge">🛒 <strong>{$cart.count}</strong></span></div>
</div>

<div class="shop">
  <div data-island="Shelf" data-props="{^shelf}"></div>
  <aside data-island="CartSummary" data-props="{^badge}"></aside>
</div>
<?php

  // The cart is the session's: product id => quantity. The three islands get the products and
  // the cart as their props; a post - op=add or op=remove with an id - changes the session's
  // cart and answers it ($padExpose), and the island that posted puts the answer into the
  // store the three share.

  $counts = padSession ( 'islandsCart', [] );

  if ( padRequestIs ( 'POST' ) ) {

    $id = (int) padRequest ( 'id', 0 );
    $op = islandsText ( 'op' );

    if ( $op == 'add' and in_array ( $id, array_column ( islandsData ( 'products' ), 'id' ) ) )
      $counts [$id] = ( $counts [$id] ?? 0 ) + 1;
    elseif ( $op == 'remove' and isset ( $counts [$id] ) and --$counts [$id] <= 0 )
      unset ( $counts [$id] );

    padSessionPut ( 'islandsCart', $counts );

  }

  $cart      = islandsCart ( $counts );
  $shelf     = [ 'products' => islandsData ( 'products' ), 'cart' => $cart ];
  $badge     = [ 'cart' => $cart ];
  $padExpose = [ 'cart' ];

?>
// The shelf, in React: its products and the first cart come from PAD's props. Adding posts to
// the page, which keeps the cart in the session and answers it - and the answer goes into the
// shared store, where the Vue summary and the Svelte badge read it too.
import { useSyncExternalStore, useState } from 'react';
import { store, post } from '../pad-islands.js';

export default function Shelf({ products, cart }) {
  const shared = store('cart', cart);
  const now = useSyncExternalStore(shared.subscribe, shared.get);
  const [busy, setBusy] = useState(0);

  const add = async (id) => {
    setBusy(id);
    try { shared.set((await post('examples/shared', { op: 'add', id })).cart); }
    finally { setBusy(0); }
  };

  const inCart = (id) => now.lines.find((line) => line.id === id)?.quantity || 0;

  return (
    <div className="shelf">
      {products.map((product) => (
        <div className="card item" key={product.id}>
          <span className="item-emoji">{product.emoji}</span>
          <strong>{product.name}</strong>
          <span className="muted small">€ {product.price.toFixed(2)}</span>
          <button className="btn-sm" disabled={busy === product.id} onClick={() => add(product.id)}>
            + Add {inCart(product.id) > 0 && <span className="badge">{inCart(product.id)}</span>}
          </button>
        </div>
      ))}
      <p className="framework-tag">React</p>
    </div>
  );
}
<script setup>
// The summary, in Vue: the same store the React shelf writes, read into a ref.
import { ref, onUnmounted } from 'vue';
import { store, post } from '../pad-islands.js';

const props = defineProps({ cart: Object });
const shared = store('cart', props.cart);
const cart = ref(shared.get());
onUnmounted(shared.subscribe((value) => { cart.value = value; }));

async function remove(id) {
  shared.set((await post('examples/shared', { op: 'remove', id })).cart);
}
</script>

<template>
  <div class="card summary">
    <h3>Your cart</h3>
    <p v-if="!cart.lines.length" class="muted small">Empty - add something from the shelf.</p>
    <div v-for="line in cart.lines" :key="line.id" class="line">
      <span>{{ line.emoji }} {{ line.name }} × {{ line.quantity }}</span>
      <button class="btn-icon btn-ghost" @click="remove(line.id)" :aria-label="'One ' + line.name + ' less'">−</button>
    </div>
    <div class="line total"><span>Total</span><span>€ {{ cart.total }}</span></div>
    <p class="framework-tag">Vue</p>
  </div>
</template>

<style scoped>
.summary h3 { margin-top: 0; }
.line { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.line.total { font-weight: 800; border-bottom: 0; }
</style>
<script>
  // The badge, in Svelte: the count of the store the others write.
  import { store } from '../pad-islands.js';

  let { cart } = $props();
  const shared = store('cart', cart);
  let count = $state(shared.get().count);

  $effect(() => shared.subscribe((value) => { count = value.count; }));
</script>

<span class="cart-badge" title="Svelte">🛒 <strong>{count}</strong></span>

<style>
  .cart-badge { display: inline-flex; gap: 8px; align-items: center; padding: 8px 14px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow); }
</style>
// pad-islands - the runtime of the islands application, for components of any framework.
//
//   islands({ Counter: react(() => import('./islands/Counter.react.jsx')) })
//
// mounts a component on every element <div data-island="Counter" data-props="{^props}"> PAD
// wrote, the JSON of data-props as its props and what PAD rendered inside as serverHtml.
// A component's code - its framework with it - is fetched when its island needs it:
//
//   data-load="eager"      at once (the default)
//   data-load="idle"       once the browser has nothing else to do
//   data-load="visible"    when the island scrolls into view
//
// react(), vue(), svelte(), preact() and solid() pair a component with its framework's
// mount; each adapter is a module of its own, so a page without a Vue island loads no Vue.
// store() is state the islands of every framework share; get() and post() talk to PAD.

const registry = {};

function adapter(load) {
  return (component) => () => Promise.all([load(), component()]).then(([a, c]) => a.island(c.default));
}

export const react  = adapter(() => import('./adapters/react.js'));
export const vue    = adapter(() => import('./adapters/vue.js'));
export const svelte = adapter(() => import('./adapters/svelte.js'));
export const preact = adapter(() => import('./adapters/preact.js'));
export const solid  = adapter(() => import('./adapters/solid.js'));

function props(element) {
  let value = {};
  try { value = JSON.parse(element.getAttribute('data-props') || '{}'); } catch (e) { console.error('pad-islands: no JSON in data-props', element); }
  if (!value || typeof value !== 'object' || Array.isArray(value)) value = { data: value };
  value.serverHtml = element.innerHTML;
  return value;
}

// Every island that mounted, told as a pad:island event and kept: a listener that came
// later - itself an island - reads what it missed with mounted().

const history = [];

function told(name, element, ms) {
  const detail = { name, element, ms, at: new Date() };
  history.push(detail);
  window.dispatchEvent(new CustomEvent('pad:island', { detail }));
}

export const mounted = () => history.slice();

function start(element) {
  if (element.padIsland) return;
  const name = element.getAttribute('data-island');
  const load = registry[name];
  if (!load) return console.warn(`pad-islands: no island named ${name}`);
  element.padIsland = 'loading';
  const began = performance.now();
  load().then((mount) => {
    element.padIsland = mount(element, props(element));
    element.classList.add('is-mounted');
    told(name, element, Math.round(performance.now() - began));
  });
}

function when(element) {
  const how = element.getAttribute('data-load') || 'eager';
  if (how === 'idle')
    return (window.requestIdleCallback || ((go) => setTimeout(go, 200)))(() => start(element));
  if (how === 'visible' && 'IntersectionObserver' in window) {
    const seen = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) { seen.disconnect(); start(element); }
    }, { rootMargin: '100px' });
    return seen.observe(element);
  }
  start(element);
}

export function islands(list) {
  Object.assign(registry, list);
  document.querySelectorAll('[data-island]').forEach(when);
}

// ------------------------------------------------------------------ shared state

const stores = {};

export function store(name, initial) {
  if (stores[name]) return stores[name];
  let value = initial;
  const listeners = new Set();
  return stores[name] = {
    get: () => value,
    set(next) {
      value = typeof next === 'function' ? next(value) : next;
      listeners.forEach((listener) => listener(value));
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    }
  };
}

// ------------------------------------------------------------------ requests back to PAD

function address(page, params) {
  let url = location.pathname + '?' + page;
  for (const [key, value] of Object.entries(params || {}))
    if (value !== undefined && value !== null && value !== '')
      url += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(value);
  return url;
}

async function send(method, page, params, body) {
  const init = { method, headers: { Accept: 'application/json' }, credentials: 'same-origin' };
  if (body) {
    init.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]')?.content || '';
    init.body = new URLSearchParams(body);
  }
  const response = await fetch(address(page, { ...params, padFormat: 'json' }), init);
  if (!response.ok) throw Object.assign(new Error(`The server answered ${response.status}`), { status: response.status });
  return response.json();
}

export const get  = (page, params) => send('GET', page, params);
export const post = (page, body) => send('POST', page, {}, body);