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
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);