PAD + islands

Load when it is needed

data-load says when an island's code is fetched: at once, when the browser is idle, or when it scrolls into view. The log says when each one came.

data-load IntersectionObserverrequestIdleCallbackimport()
Live
When each island came
Reactdata-load="eager"

Waiting to load ...

Preactdata-load="eager"

Waiting to load ...

Vuedata-load="idle"

Waiting to load ...

Soliddata-load="idle"

Waiting to load ...

Sveltedata-load="visible"

Far down the page - the island below loads only when it scrolls into view. Scroll on.

Sveltedata-load="visible"

Not loaded yet - it is not in view.

How it is built

PAD, on the server the islands, built by Vite

<div class="cols-2">
  <div>
    <div class="panel-title">When each island came</div>
    <div data-island="LoadLog"></div>
  </div>
  <div class="loads">
    {loads}
    <div class="card load-card">
      <div class="framework-head"><strong>{$framework}</strong><code class="badge">data-load="{$load}"</code></div>
      {if $load ne 'visible'}<div data-island="{$island}" data-load="{$load}" data-props="{^props}"><p class="muted small">Waiting to load ...</p></div>{/if}
    </div>
    {/loads}
  </div>
</div>

<div class="far-below">
  <p class="muted">Far down the page - the island below loads only when it scrolls into view. Scroll on.</p>
  {loads}{if $load eq 'visible'}
  <div class="card load-card">
    <div class="framework-head"><strong>{$framework}</strong><code class="badge">data-load="{$load}"</code></div>
    <div data-island="{$island}" data-load="{$load}" data-props="{^props}"><p class="muted small">Not loaded yet - it is not in view.</p></div>
  </div>
  {/if}{/loads}
</div>
<?php

  // Six islands, three ways of loading: the first two at once, two when the browser is idle,
  // two only when they scroll into view - far down the page. The log is an island too, loaded
  // at once so it hears the others mount.

  $loads = [ [ 'island' => 'ReactCounter',  'load' => 'eager',   'framework' => 'React',  'start' => 1 ],
             [ 'island' => 'PreactCounter', 'load' => 'eager',   'framework' => 'Preact', 'start' => 2 ],
             [ 'island' => 'VueCounter',    'load' => 'idle',    'framework' => 'Vue',    'start' => 3 ],
             [ 'island' => 'SolidCounter',  'load' => 'idle',    'framework' => 'Solid',  'start' => 4 ],
             [ 'island' => 'SvelteCounter', 'load' => 'visible', 'framework' => 'Svelte', 'start' => 5 ] ];

  foreach ( $loads as $at => $one )
    $loads [$at] ['props'] = [ 'start' => $one ['start'], 'framework' => $one ['framework'] ];

?>
// 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);
// The log of the loading page, in Preact: every island that mounted before it, and every
// one after (the pad:island event of pad-islands.js), with the time its code took to come.
import { html } from 'htm/preact';
import { useState, useEffect } from 'preact/hooks';
import { mounted } from '../pad-islands.js';

export default function LoadLog() {
  const [rows, setRows] = useState(mounted);
  useEffect(() => {
    setRows(mounted());                                    // what came between render and now
    const told = (event) => setRows((now) => [...now, event.detail]);
    window.addEventListener('pad:island', told);
    return () => window.removeEventListener('pad:island', told);
  }, []);
  return html`
    <ol class="load-log">
      ${rows.length === 0 && html`<li class="muted">Waiting for the first island ...</li>`}
      ${rows.map((row) => html`<li class="rise"><code>${row.at.toLocaleTimeString()}</code> <strong>${row.name}</strong> mounted - its code took ${row.ms} ms <span class="badge">${row.element.getAttribute('data-load') || 'eager'}</span></li>`)}
    </ol>`;
}