window-page
Version:
Route, setup, and build web pages
575 lines (522 loc) • 14.6 kB
JavaScript
import Loc from './loc';
import { Queue, domDeferred, UiQueue, waitStyles, loadNode } from './wait';
import Diff from 'levenlistdiff';
const ROUTE = "route";
const READY = "ready";
const BUILD = "build";
const PATCH = "patch";
const SETUP = "setup";
const PAINT = "paint";
const CLOSE = "close";
const CATCH = "catch";
const FRAGMENT = "fragment";
const Stages = [ROUTE, READY, BUILD, PATCH, SETUP, PAINT, FRAGMENT, CATCH, CLOSE];
const NodeEvents = [BUILD, PATCH, SETUP, PAINT, FRAGMENT, CLOSE];
const runQueue = new Queue();
const uiQueue = new UiQueue();
const chainsMap = {};
const connects = new Map();
export default class State extends Loc {
static Stages = Stages;
static NodeEvents = NodeEvents;
static state = new State();
data = {};
#stage;
#chains = {};
#emitter;
#emitters;
#referrer;
constructor(loc) {
super(loc);
for (const name of Stages) this.#initChain(name);
}
format(loc) {
return (new Loc(loc)).toString();
}
parse(str) {
return new Loc(str);
}
copy() {
const inst = new State(this);
inst.#emitter = this.#emitter;
inst.#referrer = this.#referrer;
inst.#chains = this.#chains;
inst.#stage = this.#stage;
Object.assign(inst, this); // also copy extra properties
return inst;
}
get stage() {
return this.#stage;
}
get referrer() {
return this.#referrer;
}
#rebind() {
State.state = window.Page = this;
}
connect(listener, node) {
const methods = [];
if (!node) node = listener;
let proto = listener.constructor;
proto = proto === Object ? listener : proto.prototype;
for (const name of Object.getOwnPropertyNames(proto)) {
let all = false;
let key = name;
if (key.startsWith('handleAll') || key.startsWith('captureAll')) {
key = key.replace('All', '');
all = true;
}
if (key.startsWith('handle') && key != 'handleEvent') {
methods.push([all ? window : node, name, key.slice(6).toLowerCase(), false]);
} else if (name.startsWith('capture') && name != 'captureEvent') {
methods.push([all ? window : node, name, key.slice(7).toLowerCase(), true]);
}
}
for (const name of methods) {
name[4] = e => {
const chain = State.state.#chains[State.state.#stage];
listener[name[1]](e, chain.state);
};
name[0].addEventListener(name[2], name[4], name[3]);
}
connects.set(listener, methods);
for (const name of NodeEvents) {
if (listener[name]) this.chain(name, listener);
}
}
disconnect(listener) {
for (const name of NodeEvents) {
if (listener[name]) {
this.unchain(name, listener);
}
}
const methods = connects.get(listener);
if (methods) {
for (const name of methods) {
name[0].removeEventListener(name[2], name[4], name[3]);
}
connects.delete(listener);
}
if (listener[CLOSE]) {
// if setup, run close once
const prox = state => {
this.unchain(SETUP, prox);
return listener[CLOSE](state);
};
this.chain(SETUP, prox);
}
}
#prerender(ok) {
const root = document.documentElement;
if (ok === undefined) ok = root.dataset.prerender == 'true';
else if (ok === true) root.setAttribute('data-prerender', 'true');
else if (ok === false) root.removeAttribute('data-prerender');
return ok;
}
run(opts) {
this.#emitter = document.createElement('div');
return runQueue.queue(() => this.#run(opts));
}
async #run(opts = {}) {
this.#rebind();
this.#stage = true;
if (opts.data) Object.assign(this.data, opts.data);
let samePathname = false;
let sameQuery = false;
let sameHash = false;
const refer = this.#referrer;
if (refer == this) {
throw new Error("state and referrer should be distinct");
}
if (!refer) {
sameHash = this.hash == '';
} else {
samePathname = this.samePathname(refer);
if (samePathname) {
sameQuery = this.sameQuery(refer);
this.#emitter = refer.#emitter;
}
if (sameQuery) sameHash = this.sameHash(refer);
refer.#emitters = new Set(document.head.querySelectorAll('script'));
}
await domDeferred();
let prerendered = this.#prerender();
// prerendered == referrer with same pathname/query
// not prerendered == no referrer
if (prerendered && !refer) {
samePathname = true;
// assumes full prerendering
sameQuery = true;
}
let { vary } = opts;
if (vary === true) {
vary = BUILD;
}
if (vary == BUILD) {
prerendered = sameHash = sameQuery = samePathname = false;
} else if (vary == PATCH) {
sameHash = sameQuery = false;
} else if (vary == FRAGMENT) {
sameHash = false;
}
try {
if (!samePathname || !prerendered) {
await this.runChain(ROUTE);
if (this.doc && this.doc != document) {
await this.#load(this.doc);
prerendered = this.#prerender();
}
this.doc = document;
}
this.#prerender(true);
await this.runChain(READY);
if (!prerendered || !samePathname) {
await this.runChain(BUILD);
}
if (!prerendered || !sameQuery) {
await this.runChain(PATCH);
}
uiQueue.run(async () => {
try {
if (refer) window.removeEventListener('popstate', refer);
window.addEventListener('popstate', this);
await waitStyles();
if (refer && !samePathname) {
await refer.runChain(CLOSE);
}
if (!refer || !samePathname) {
await this.runChain(SETUP);
} else {
await this.runChain(SETUP, true);
}
await this.runChain(PAINT);
if (!sameHash) await this.runChain(FRAGMENT);
} catch (err) {
console.error(err);
}
});
} catch(err) {
this.error = err;
await this.runChain(CATCH);
const { error } = this;
this.error = null;
if (error) throw error;
}
return this;
}
#initChain(stage) {
const chain = this.#chains[stage] = {};
chain.started = false;
chain.hold = Promise.withResolvers();
chain.after = new Queue();
// block after
chain.after.queue(() => chain.hold.promise);
chain.current = new Queue();
// unblock after when current.done is resolved
chain.current.done.then(() => {
chain.hold.resolve();
});
chain.done = chain.after.done.then(() => {
Object.assign(this, chain.state);
return this;
});
return chain;
}
#getChain(stage) {
return this.#chains[stage] ?? this.#initChain(stage);
}
#startChain(stage) {
const chain = this.#getChain(stage);
this.#stage = stage;
chain.state = this.copy();
chain.started = true;
return chain;
}
runChain(stage, future) {
const chain = this.#startChain(stage);
if (!future) {
const e = new CustomEvent(`page${stage}`, {
bubbles: true,
cancelable: true,
detail: chain
});
for (const node of (this.#emitters ?? document.head.querySelectorAll('script'))) {
node.dispatchEvent(e);
}
if (this.#emitter) this.#emitter.dispatchEvent(e);
}
if (chain.current.length == 0) chain.hold.resolve();
return chain.done;
}
async chain(stage, fn) {
const chain = this.#getChain(stage);
if (!fn) return chain.done;
const stageMap = chainsMap[stage] ?? (chainsMap[stage] = new Map());
let curem = document.currentScript;
if (curem?.parentNode?.nodeName == "HEAD") {
if (fn.parentNode && fn.parentNode.nodeName != "HEAD") curem = null;
}
const emitter = curem ?? this.#emitter;
let lfn = stageMap.get(fn);
if (!lfn) {
lfn = {
fn,
run(e) {
const chain = e.detail;
const q = chain.current;
if (lfn.fn) q.queue(() => {
if (!q.stopped && lfn.fn) return chain.state.#runFn(lfn.fn);
});
return q;
},
emitters: new Set()
};
stageMap.set(fn, lfn);
}
if (!lfn.emitters.has(emitter)) {
lfn.emitters.add(emitter);
emitter.addEventListener('page' + stage, lfn.run);
}
if (!chain.started) {
// pass
} else if (chain.current.stopped) {
await lfn.run({ detail: chain });
} else {
// not finished
chain.current.queue(() => {
return lfn.run({ detail: chain });
});
}
return chain.done;
}
finish(fn) {
const stage = this.#stage;
if (!stage) throw new Error("Use state.finish inside a chain");
const chain = this.#chains[stage];
if (!chain) {
console.warn("state.finish must be called from chain listener");
} else if (fn) {
chain.after.queue(() => chain.state.#runFn(fn));
} else {
const fn = Promise.withResolvers();
chain.after.queue(() => chain.state.#runFn(fn.resolve));
return fn.promise;
}
return this;
}
unchain(stage, fn) {
const stageMap = chainsMap[stage];
const lfn = stageMap?.get(fn);
if (!lfn) return;
stageMap.delete(fn);
// the task in queue is not removed
// yet the function is no longer called from the task
for (const emitter of lfn.emitters) {
emitter.removeEventListener('page' + stage, lfn.run);
}
// chain might have queued lfn.fn call
lfn.fn = null;
}
async #runFn(fn) {
const stage = this.#stage;
const n = 'chain' + stage[0].toUpperCase() + stage.slice(1);
const meth = fn?.[n] ?? fn?.[stage];
// eslint-disable-next-line no-use-before-define
try {
if (meth && typeof meth == "function") {
return await meth.call(fn, this);
} else if (typeof fn == "function") {
return await fn(this);
} else {
console.warn(stage, "function not found in", fn);
}
} catch (err) {
console.error("Page." + stage, err);
}
}
async #load(ndoc) {
if (ndoc.ownerDocument) ndoc = ndoc.ownerDocument;
if (!ndoc.documentElement) {
throw new Error("Router expects documentElement");
}
const root = document.documentElement;
const nroot = document.adoptNode(ndoc.documentElement);
const nhead = nroot.querySelector('head');
const nbody = nroot.querySelector('body');
const selOn = 'script:not([type]),script[type="text/javascript"],script[type="module"],link[rel="stylesheet"]';
const selOff = 'link[rel="none"],script[type="none"]';
this.#updateAttributes(root, nroot);
// disable all scripts and styles
for (const node of nhead.querySelectorAll(selOn)) {
const type = node.nodeName == "SCRIPT" ? 'type' : 'rel';
if (node[type]) node.setAttribute('priv-' + type, node[type]);
node[type] = 'none';
}
// previous styles are not removed immediately to avoid fouc
const oldstyles = this.mergeHead(nhead);
// preload and start styles
const parallels = [];
const serials = [];
for (const node of document.head.querySelectorAll(selOff)) {
const isScript = node.nodeName == "SCRIPT";
const { src, as, cross } = isScript ? {
src: node.getAttribute('src'),
as: 'script',
cross: node.crossOrigin != null ? 'crossorigin=' + node.crossOrigin : ''
} : {
src: node.getAttribute('href'),
as: 'style',
cross: ''
};
if (src && !src.startsWith('data:')) {
// preload when in head
node.insertAdjacentHTML('beforebegin',
`<link rel="preload" href="${src}" as="${as}" ${cross}>`);
}
if (!isScript) {
parallels.push(loadNode(node));
} else {
serials.push(node);
}
}
await Promise.all(parallels);
await this.mergeBody(nbody, document.body);
for (const node of oldstyles) node.remove();
// scripts must be run in order
for (const node of serials) {
await loadNode(node);
}
}
mergeHead(node) {
this.#updateAttributes(document.head, node);
const from = document.head;
const to = node;
const collect = [];
const list = Diff(from.children, to.children, child => {
const url = child.src ?? child.href;
const rel = child.rel == "none" ? "stylesheet" : child.rel;
if (url) return `${child.nodeName}_${rel ?? ''}_${url}`;
else return child.outerHTML;
});
for (const patch of list) {
const ref = from.children[patch.index];
switch (patch.type) {
case Diff.INSERTION:
from.insertBefore(patch.item, ref);
break;
case Diff.SUBSTITUTION:
if (ref.nodeName == "LINK" && ref.rel == "stylesheet") {
from.insertBefore(patch.item, ref);
collect.push(ref);
} else {
from.replaceChild(patch.item, ref);
}
break;
case Diff.DELETION:
if (ref.nodeName == "LINK" && ref.rel == "stylesheet") {
collect.push(ref);
} else {
ref.remove();
}
break;
}
}
return collect;
}
mergeBody(node) {
document.body.parentNode.replaceChild(node, document.body);
}
#updateAttributes(from, to) {
const map = {};
for (const att of to.attributes) map[att.name] = att.value;
for (const att of Array.from(from.attributes)) { // mind the live collection
const val = map[att.name];
if (val === undefined) {
from.removeAttribute(att.name);
} else if (val != att.value) {
from.setAttribute(att.name, val);
}
delete map[att.name];
}
for (const name in map) from.setAttribute(name, map[name]);
}
replace(loc, opts) {
return this.#historyMethod('replace', loc, opts);
}
push(loc, opts) {
return this.#historyMethod('push', loc, opts);
}
reload(opts) {
if (!opts) opts = {};
else if (opts === true) opts = { vary: true };
let vary = opts.vary;
if (vary == null) {
if (this.#chains[BUILD]?.current?.count) {
vary = BUILD;
} else if (this.#chains[PATCH]?.current?.count) {
vary = PATCH;
} else if (this.#chains[FRAGMENT]?.current?.count) {
vary = FRAGMENT;
}
opts.vary = vary;
}
return this.replace(this, opts);
}
save() {
return this.#historySave('replace');
}
handleEvent(e) {
if (e.type == "popstate") {
const state = this.#stateFrom(e.state) || new State();
this.#historyMethod('push', state, { pop: true, state: true });
}
}
#stateTo() {
return {
href: this.toString(),
data: this.data,
stage: this.#stage
};
}
#stateFrom(from) {
if (!from || !from.href) return;
const state = new State(from.href);
state.data = from.data;
state.#stage = from.stage;
return state;
}
#historySave(method) {
if (!window.history) return false;
const to = this.#stateTo();
window.history[method + 'State'](to, document.title, to.href);
return true;
}
#historyMethod(method, loc, opts) {
const refer = State.state;
const copy = opts?.state ? loc : new State(loc);
copy.#referrer = refer;
if (!copy.sameDomain(refer)) {
if (method == "replace") console.info("Cannot replace to a different origin");
document.location.assign(copy.toString());
} else {
copy.run(opts).then(() => {
if (!opts?.pop) copy.#historySave(method);
});
}
return copy;
}
}
for (const stage of Stages) {
Object.defineProperties(State.prototype, {
[stage]: {
value: function (fn) {
return this.chain(stage, fn);
}
},
[`un${stage}`]: {
value: function (fn) {
return this.unchain(stage, fn);
}
}
});
}