UNPKG

hy-push-state

Version:

Turn static web sites into dynamic web apps

221 lines (195 loc) 7.5 kB
// # src / mixin / setup.js // Copyright (c) 2018 Florian Klampfer <https://qwtel.com/> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see <http://www.gnu.org/licenses/>. import { Subject, defer, fromEvent, merge, NEVER } from "rxjs/_esm5"; import { catchError, tap, distinctUntilChanged, filter, map, mapTo, partition, pairwise, share, startWith, switchMap, takeUntil, withLatestFrom, } from "rxjs/_esm5/operators"; import { URL } from "../url"; import { HINT, PUSH, POP } from "./constants"; import { unsubscribeWhen } from "./operators"; import { helperMixin } from "./methods"; import { historyMixin } from "./history"; import { fetchMixin } from "./fetching"; import { updateMixin } from "./update"; import { eventMixin } from "./events"; import { eventListenersMixin } from "./event-listeners"; export const setupObservablesMixin = C => class extends eventListenersMixin( eventMixin(updateMixin(fetchMixin(historyMixin(helperMixin(C))))) ) { // A compare function for contexts, used in combination with `distinctUntilChanged`. // We use `cacheNr` as it is a convenient (hacky) way of circumventing // `distinctUntilChanged` when retrying requests. compareContext(p, q) { return p.url.href === q.url.href && p.error === q.error && p.cacheNr === q.cacheNr; } // ### Setup observable // This functions sets up the core observable pipeline of this component. setupObservables() { this.cacheNr = 1; // For now, we take for granted that we have a stream of all `PUSH` events (loading a new page by // clicking on a link) and `HINT` events (probable click on a link) which are `pushSubject` and // `hintSubject` respectively. this.pushSubject = new Subject(); this.hintSubject = new Subject(); // TODO: doc const push$ = this.pushSubject.pipe( takeUntil(this.subjects.disconnect), map(event => ({ type: PUSH, url: new URL(event.currentTarget.href, this.href), anchor: event.currentTarget, event, cacheNr: this.cacheNr, })), filter(this.isPushEvent.bind(this)), tap(({ event }) => { event.preventDefault(); this.saveScrollPosition(); }) ); // In additon to `HINT` and `PUSH` events, there's also `POP` events, which are caused by // modifying the browser history, e.g. clicking the back button, etc. const pop$ = fromEvent(window, "popstate").pipe( takeUntil(this.subjects.disconnect), filter(() => window.history.state && window.history.state[this.histId()]), map(event => ({ type: POP, url: new URL(window.location, this.href), event, cacheNr: this.cacheNr, })) ); const reload$ = this.reload$.pipe(takeUntil(this.subjects.disconnect)); // TODO: doc const [hash$, page$] = merge(push$, pop$, reload$) .pipe( startWith({ url: new URL(this.initialHref) }), // HACK: make hy-push-state mimic window.location? tap(({ url }) => (this._url = url)), pairwise(), share(), partition(this.isHashChange) ) .map(obs$ => obs$.pipe( map(([, x]) => x), share() ) ); // TODO: doc const hint$ = this.subjects.prefetch.pipe( switchMap(prefetch => { if (!prefetch) return NEVER; // We don't want to prefetch (i.e. use bandwidth) for a _possible_ page load, // while a _certain_ page load is going on. // The `pauser$` observable let's us achieve this. // Needs to be deferred b/c of "cyclical" dependency. const pauser$ = defer(() => // A page change event means we want to pause prefetching, while // a response event means we want to resume prefetching. merge(page$.pipe(mapTo(true)), this.fetch$.pipe(mapTo(false))) ) // Start with `false`, i.e. we want the prefetch pipelien to be active .pipe(startWith(false), share()); return this.hintSubject.pipe( takeUntil(this.subjects.disconnect), unsubscribeWhen(pauser$), map(event => ({ type: HINT, url: new URL(event.currentTarget.href, this.href), anchor: event.currentTarget, event, cacheNr: this.cacheNr, })), filter(this.isHintEvent.bind(this)) ); }) ); // The stream of (pre-)fetch events. // Includes definitive page change events do deal with unexpected page changes. const prefetch$ = merge(hint$, page$).pipe( // Don't abort a request if the user "jiggles" over a link distinctUntilChanged(this.compareContext.bind(this)), switchMap(this.makeRequest.bind(this)), // Start with some value so `withLatestFrom` below doesn't "block" startWith({ url: {} }), share() ); // TODO: doc this.fetch$ = page$.pipe( tap(context => { this.updateHistoryState(context); this.onStart(context); }), withLatestFrom(prefetch$), switchMap(this.getResponse.bind(this, prefetch$)), share() ); // TODO: doc const [fetchOk$, fetchError$] = this.fetch$.pipe(partition(({ error }) => !error)); // TODO: doc const main$ = fetchOk$.pipe( map(this.responseToContent.bind(this)), tap(context => { this.onReady(context); this.updateDOM(context); this.onAfter(context); this.manageScrollPostion(context); }), tap({ error: e => this.onDOMError(e) }), catchError((e, c) => c), // If the experimental script feature is enabled, // scripts tags have been stripped from the content, // and this is where we insert them again. switchMap(this.reinsertScriptTags.bind(this)), tap({ error: e => this.onError(e) }), catchError((e, c) => c) ); // #### Subscriptions // Subscribe to main observables. main$.subscribe(this.onLoad.bind(this)); // Subscribe to hash observables. hash$.subscribe(context => { this.updateHistoryStateHash(context); this.manageScrollPostion(context); }); // Subscribe to the fetch error branch. fetchError$.subscribe(this.onNetworkError.bind(this)); // Fire `progress` event when fetching takes longer than expected. page$ .pipe( switchMap(context => defer(() => this.animPromise).pipe(takeUntil(this.fetch$), mapTo(context)) ) ) .subscribe(this.onProgress.bind(this)); // TODO: doc this.setupEventListeners(); } };