README
This project is still in its infancy, use it with care and expect some changes to be shipped before official release.
STIMULANT
Functional, blazing fast, lightweight (9kb gzipped) and feature full new generation pjax solution for achieving instantaneous page navigations in SSR web applications. Stimulant couples perfectly with stimulus.js and supports multiple fragment replacements, advanced pre-fetching capabilities which execute via mouse, pointer, touch or intersection events and employs a snapshot caching engine to prevent subsequent requests from occurring.
Features
- Simple and painless integration
- Pre-fetching capabilities using click, hover and intersect
- Snapshot caching engine and per-page state control.
- Powerful target and document triggered lifecycle event dispatching
- Client side DOM hydration approach approach
- Supports both append and prepend fragment replacements
- Dependency management system
Demo
Stimulant is used on the BRIXTOL TEXTILES webshop.
Why?
The landscape of pjax based solution has become rather scarce. The current bread winners either offer the same thing or for our use case were vastly over engineered. Stimulant couples together various techniques found to be the most effective in enhancing the performance of SSR rendered web application which are fetching pages over the wire.
Install
pnpm add stimulant
Because pnpm is dope and does dope shit.
Usage
To initialize, call stimulant.connect()
in your bundle and optionally pass preset configuration. By default it will replace the entire <body>
fragment upon each navigation. You should define a set of targets[]
whose inner contents should change on a per-page basis for optimal performance.
The typings provided in this package will describe each option in good detail, below are the defaults and all options are optional.
import * as stimulant from "stimulant";
stimulant.connect({
targets: ["body"], // Define fragments to be replaced here!
cache: {
enable: true,
reverse: true,
limit: 50
},
requests: {
timeout: 30000,
async: true,
poll: 150 // You should leave this alone.
},
prefetch: {
preempt: undefined, // Accepts [] or { '/path': [] }
mouseover: {
enable: true, // You want the speed? leave this as true.
trigger: 'attribute',
threshold: 100,
},
intersect: {
enable: true,
options: {
rootMargin: "0px 0px 0px 0px",
threshold: 1.0,
},
},
},
progress: {
enable: true,
threshold: 500,
options: {
enable: true,
threshold: 850,
style: {
render: true,
colour: 'black',
height: '2px'
},
options: {
minimum: 0.1,
easing: 'ease',
speed: 225,
trickle: true,
trickleSpeed: 225,
showSpinner: false
}
}
});
Real World
Below is a real world example you can use to better understand how this module works so you can apply it into your web application. We are working on providing a live demonstration for more advanced use cases, but the below example should give you a good understanding and help you in understanding how to leverage the module.
Example
The first thing we want to do is make a connection with Stimulant. In your JavaScript bundle, we need to initialize. Our example web application has 3 pages, the home page, about page and contact page. We are going to instruct stimulant to replace the <nav>
and <main>
fragments on every visit and then we are going to leverage data-$
attributes to replace an additional fragment when we navigate to the contact page.
JavaScript Bundle
import * as stimulant from "stimulant";
export default () => {
stimulant.connect({
targets: [
"nav",
"main"
],
})
}
Home Page
Below we have a very basic Home Page with stimulant wired up and all `` elements will be intercepted and cached. SSR web application (in most cases) will only ever have a couple of fragments that change between navigation, so keeping to that logic lets begin..
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Home Page</title>
<script src="/bundle.js"></script>
</head>
<body>
<header>
<h1>Stimulant Example</h1>
<!-- THIS FRAGMENT WILL BE REPLACED -->
<nav>
<!-- This link will be intercepted -->
<a
href="/home"
class="active">Home</a>
<!-- These links will be pe-fetched on hover -->
<a
href="/about"
data-$prefetch="hover">About</a>
<!-- This link will replace the #foo fragment -->
<a
href="/contact"
data-$replace="(['#foo'])"
data-$prefetch="hover">Faq</a>
</nav>
</header>
<!-- THIS FRAGMENT WILL BE REPLACED -->
<main>
<h1>Welcome to the home page</h1>
<div class="container">
Brixtol Textiles is a Swedish apparel brand!
</div>
</main>
<div id="foo">
This fragment will not be touched until /contact is clicked
</div>
<footer>
This will not be touched during navigation
</footer>
</body>
</html>
About Page
The about page in our web application would look practically identical to the home page. We instructed stimulant to pre-fetch this page upon hover, so navigating to this page will be instantaneous. The about page only has some minor differences, but for the sake of clarity, lets have look:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>About Page</title>
<script src="/bundle.js"></script>
</head>
<body>
<header>
<h1>Stimulant Example</h1>
<!-- THIS FRAGMENT WILL BE REPLACED -->
<nav>
<!-- This link will be intercepted -->
<a
href="/home">Home</a>
<!-- These links will be pe-fetched on hover -->
<a
href="/about"
class="active"
data-$prefetch="hover">About</a>
<!-- This link will replace the #foo fragment -->
<a
href="/contact"
data-$replace="(['#foo'])"
data-$prefetch="hover">Contact</a>
</nav>
</header>
<!-- THIS FRAGMENT WILL BE REPLACED -->
<main>
<h1>Welcome to the About Page</h1>
<div class="container">
Brixtol Textiles makes jackets out of recycled PET bottles.
<p>Producing clothing in a sustainable way is the future!</p>
</div>
</main>
<div id="foo">
This fragment will not be touched until /contact is clicked
</div>
<footer>
This will not be touched during navigation
</footer>
</body>
</html>
Contact Page
The contact page will replace an additional fragment with the id value of foo
which we instructed via attribute annotation. When the contact page link is hovered the page will be saved to cache, upon visit the <nav>
, <main>
and <div id="foo">
fragments will be replaced.
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Contact Page</title>
<script src="/bundle.js"></script>
</head>
<body>
<header>
<h1>Stimulant Example</h1>
<!-- THIS FRAGMENT WILL BE REPLACED -->
<nav>
<!-- This link will be intercepted -->
<a
href="/home">Home</a>
<!-- These links will be pe-fetched on hover -->
<a
href="/about"
data-$prefetch="hover">About</a>
<!-- This link will replace the #foo fragment -->
<a
href="/contact"
class="active"
data-$replace="(['#foo'])"
data-$prefetch="hover">Contact</a>
</nav>
</header>
<!-- THIS FRAGMENT WILL BE REPLACED -->
<main>
<h1>Welcome to the Contact Page</h1>
<div class="container">
This is contact page of our example! The below fragment was replaced too!
</div>
</main>
<!-- THIS FRAGMENT WAS REPLACE VIA ATTRIBUTE INSTRUCTION -->
<div id="foo">
This fragment was replaced!
</div>
<footer>
This will not be touched during navigation
</footer>
</body>
</html>
Lifecycle Events
Lifecycle events are dispatched to the document upon each navigation. You can access contextual information from within event.detail
or cancel events with preventDefault()
or by returning boolean false
to prevent execution from occurring.
// Triggered when a prefetch is triggered
document.addEventListener("$prefetch");
// Triggered when a mousedown event occurs on a link
document.addEventListener("$trigger");
// Triggered before a page is fetched over XHR
document.addEventListener("$request");
// Triggered before a page is cached
document.addEventListener("$cache");
// Triggered before a page or fragment is rendered
document.addEventListener("$render");
// Triggered before a fragment is hydrated
document.addEventListener("$hydrate"); // { detail: { target: HTMLElement } }
// Triggered after a page has rendered
document.addEventListener("$load");
// Triggered when a JavaScript module is loaded
document.addEventListener("$module");
Methods
In addition to Lifecycle events, you also have a list of methods available. Methods will allow you some basic programmatic control of the Stimulant session.
// Check to see if Stimulant is supported by the browser
stimulant.supported: boolean
// Connects Stimulant, called upon initialization
stimulant.connect(options?): void
// Execute a programmatic visit
stimulant.visit(url?, options?): Promise<Page{}>
// Access the cache, pass in href for specific record
stimulant.cache(url?): Page{}
// Clears the cache, pass in href to clear specific record
stimulant.clear(url?): void
// Returns a UUID string via nanoid
stimulant.uuid(size = 16): string
// Reloads the current page
stimulant.reload(): Page{}
// Disconnects Stimulant
stimulant.disconnect(): void
$ Character
Stimulus uses the $
character to define attributes and stimulant related references.
Attributes
Link elements can be annotated with data-$
attributes. You can control how pages are rendered by passing the below attributes on <a>
elements.
data-$eval
Used on resources contained within <head>
fragment like styles and scripts. Use this attribute if you want stimulant to evaluate scripts and/or stylesheets. This option accepts a false
value so you can define which scripts to execute on each navigation. By default, stimulant will run and evaluate all <script>
tags it detects for every page visit but will not re-evaluate <script src="*"></script>
tags.
If a script tag is detected on stimulant navigation and is using
data-$eval="false"
it will execute only once upon the first visit but never again after that.
Example
<script>
console.log('I will run on every navigation');
</script>
<!-- script will also run once if detected on stimulant navigation -->
<script data-$eval="false">
console.log('I will run on initialization only!');
</script>
data-$disable
Place on href
elements you don't want Stimulant navigation to be executed. When a link element is annotated with data-$disable
a normal page navigation will be executed and cache will be cleared.
Example
Clicking this link will clear cache and a normal page navigation will be executed.
<a href="*" data-$disable></a>
data-$track
Place on elements to track on a per-page basis that might otherwise not be contained within target elements.
Example
Lets assume you are navigating from Page 1
to Page 2
and #main
is your defined target. When you navigate from Page 1
only the #main
target will be replaced and any other dom elements will be skipped which are not contained within #main
. Elements located outside of target/s that do no exist on previous or future pages will be added.
Page 1
<nav>
<a href="/page-1" class="active">Page 1</a>
<a href="/page-2">Page 2</a>
</nav>
<div id="#main">
<div class="block">I will be replaced, I am active on every page.</div>
</div>
Page 2
<nav>
<a href="/page-1">Page 1</a>
<a href="/page-2" class="active">Page 2</a>
</nav>
<div id="#main">
<div class="block">I will be replaced, I am active on every page.</div>
</div>
<!-- This element will be appended to the dom -->
<div data-$track>
I am outside of target and will be tracked if Stimulant was initialized on Page 1
</div>
<!-- This element will not be appended to the dom -->
<div>I will not be tracked unless Stimulant was initialized on Page 2</div>
If Stimulant was initialized on
Page 2
then Stimulant would have knowledge of its existence before navigation. In such a situation, Stimulant will mark the tracked element internally.
data-$hydrate
Executes a replacement of the defined elements. Hydrate is different from replace
, append
and prepend
methods in the sense that the those are combined with your defined targets
. When calling Hydrate, it will run precedence over targets
and for the visit it will replace only the element/s provided.
(['target'])
(['target' , 'target'])
Example
<a
href="*"
data-$hydrate="(['.price', '.cart-count'])">
Link
</a>
<div>
The next navigation will replace all elements with class "price"
and the elements with class "cart-count" - If you have defined
the element "#main" as a "target" in your connection, a replacement
will not be made on that element, instead the elements defined in
"data-$hydrate" will become the target/s.
</div>
<span class="cart-count">1</span>
<span class="price">€ 450</span>
<div id="main">
<img src="*">
<ul>
<li>Great Product</li>
<li class="price">€ 100</li>
<li>Awesome Product</li>
<li class="price">€ 200</li>
<li>Cool Product</li>
<li class="price">€ 300</li>
</ul>
</div>
data-$replace
Executes a replacement of defined targets, where each target defined in the array is replaced.
(['target'])
(['target' , 'target'])
Example
<a
href="*"
data-$replace="(['#target1', '#target2'])">
Link
</a>
<div id="target1">
I will be replaced on next navigation
</div>
<div id="target2">
I will be replaced on next navigation
</div>
data-$prepend
Executes a prepend visit, where [0]
will prepend itself to [1]
defined in that value. Multiple prepend actions can be defined. Each prepend action is recorded are marked.
(['target' , 'target'])
(['target' , 'target'], ['target' , 'target'])
Example
PAGE 1
<a
href="*"
data-$prepend="(['#target-1', '#target-2'])">
Page 2
</a>
<div id="target-1">
I will prepend to target-2 on next navigation
</div>
<div id="target-2">
<p>target-1 will prepended to me on next navigation</p>
</div>
PAGE 2
<a
href="*"
data-$prepend="(['#target-1', '#target-2'])">
Page 2
</a>
<div id="target-2">
<!-- An action reference record is applied -->
<div data-$action="xxxxxxx">
I am target-1 and have been prepended to target-2
</div>
<p>target-1 is now prepended to me</p>
</div>
data-$prefetch
Prefetch option to execute. Accepts either intersect
or hover
value. When intersect
is provided a request will be dispatched and cached upon visibility via Intersection Observer, whereas hover
will dispatch a request upon a pointerover (mouseover) event.
On mobile devices the
hover
value will execute on atouchstart
event
Example
<!-- This link will be prefetch when it is hovered -->
<a data-$prefetch="hover" href="*"></a>
<!-- This link will be prefetch when it is in viewport -->
<a data-$prefetch="intersect" href="*"></a>
data-$threshold
Set the threshold delay timeout for hover prefetches. By default, this will be set to 100
or whatever preset configuration was defined in Stimulant.connect()
but you can override those settings by annotating the link with this attribute.
Example
<!-- hover prefetch will begin according to the connect preset -->
<!-- prefetch will not be initialized if a click was detected before threshold -->
<a data-$prefetch="hover" href="*"></a>
<!-- prefetch will begin 500ms after hover but will cancel if mouse existed before threshold -->
<a data-$prefetch="hover" data-$threshold="500" href="*"></a>
<!-- Prefetch will begin once this link becomes visible in viewport -->
<a data-$prefetch="intersect" href="*"></a>
data-$position
Scroll position of the next navigation. Space separated expression with colon separated prop and value.
Example
<!-- This next navigation will load at 1000px from top of page -->
<a data-$position="y:1000 x:0" href="*"></a>
data-$cache
Controls the caching engine for the link navigation. Accepts false
, reset
or clear
value. Passing in false
will execute a Stimulant visit that will not be saved to cache and if the link exists in cache it will be removed. When passing reset
the cache record will be removed, a new Stimulant visit will be executed and its result saved to cache. The clear
option will clear the entire cache.
Example
<a data-$cache="false" href="*"></a>
data-$history
Controls the history pushstate for the navigation. Accepts false
, replace
or push
value. Passing in false
will prevent this navigation from being added to history. Passing in replace
or push
will execute its respective value to pushstate to history.
Example
<!-- the navigation not be pushed to history -->
<a data-$history="false" href="*"></a>
data-$progress
Controls the progress bar delay. By default, progress will use the threshold defined in configuration presets defined upon connection, else it will use the value defined on link attributes. Passing in a value of false
will disable the progress from showing.
Example
<!-- Progress bar will be displayed if the request exceeds 500ms -->
<a data-$progress="500" href="*"></a>
State
Each page visited has a state value. Page state is immutable and created for every unique url /path
or /pathname?query=param
value that has been encountered throughout the Stimulant session. The state value of each page is added to its pertaining History stack record and will referenced on subsequent visits. This approach drastically improves TTFB.
Navigation sessions begin once a Stimulant connection has been established and ends when a browser refresh is executed or url origin changes.
Read
You can access a readonly copy of page via the event.details.state
property passed in certain dispatched lifecycle events or via the stimulant.cache()
method. The caching engine used by this Stimulant variation acts as mediator when a session begins. When you access page state via the stimulant.cache()
method you are given a bridge to the object that holds all active session cache data.
Write
State modifications are carried out via link attributes, when executing a programmatic visit using the Stimulant.visit()
method or from within dispatched events. When using the Stimulant.visit()
method you can pass state modification via the options
parameter and will be merged before the visit begins. Though this method will only allow you to modify the next navigation you should avoid modifying state outside of the available methods and instead treat it as readonly.
interface IPage {
/**
* The list of fragment target element selectors defined upon connection.
* Targets are inherited from `Stimulant.connect()` presets.
*
* > You cannot override the targets but you can skip replacements using
* `hydrate` to replace specific fragments.
*
* @example
* ['#main', '.header', '[data-attr]', 'header']
*/
readonly targets?: string[];
/**
* The URL cache key and current url path
*/
url?: string;
/**
* UUID reference to the page snapshot HTML Document element
*/
snapshot?: string;
/**
* The Document title
*/
title?: string;
/**
* Should this fetch be pushed to history
*/
history?: boolean;
/**
* List of fragments to replace. When `hydrate` is used,
* it will run precedence over `targets` and execute replacements
* on the triggered page fragments.
*
* @example
* ['#main', '.header', '[data-attr]', 'header']
*/
hyrdate?: null | string[];
/**
* List of fragment element selectors. Accepts any valid
* `querySelector()` string.
*
* @example
* ['#main', '.header', '[data-attr]', 'header']
*/
replace?: null | string[];
/**
* List of fragments to append from and to. Accepts multiple.
*
* @example
*
* [['#main', '.header'], ['[data-attr]', 'header']]
*/
append?: null | Array<[from: string, to: string]>;
/**
* List of fragments to be prepend from and to. Accepts multiple.
*
* @example
*
* [['#main', '.header'], ['[data-attr]', 'header']]
*/
prepend?: null | Array<[from: string, to: string]>;
/**
* Controls the caching engine for the link navigation.
* Option is enabled when `cache` preset config is `true`.
* Each Stimulant link can set a different cache option.
*/
cache?: boolean | 'reset' | 'clear';
/**
* Define mouseover timeout from which fetching will begin
* after time spent on mouseover
*
* @default 100
*/
threshold?: number;
/**
* Define proximity prefetch distance from which fetching will
* begin relative to the cursor offset of href elements.
*
* @default 0
*/
proximity?: number;
/**
* Progress bar threshold delay
*
* @default 350
*/
progress?: boolean | number;
/**
* Scroll position of the next navigation.
*
* ---
* `x` - Equivalent to `scrollLeft` in pixels
*
* `y` - Equivalent to `scrollTop` in pixels
*/
position?: {
y: number;
x: number;
};
/**
* Location URL
*/
location?: {
/**
* The URL origin name
*
* @example
* 'https://website.com'
*/
origin?: string;
/**
* The URL Hostname
*
* @example
* 'website.com'
*/
hostname?: string;
/**
* The URL Pathname
*
* @example
* '/pathname' OR '/pathname/foo/bar'
*/
pathname?: string;
/**
* The URL search params
*
* @example
* '?param=foo&bar=baz'
*/
search?: string;
/**
* The URL Hash
*
* @example
* '#foo'
*/
hash?: string;
/**
* The previous page path URL, this is also the cache identifier.
* If `cache.reverse` is `true` then preemptive fetches will be
* executed to this location.
*
* @example
* '/pathname' OR '/pathname?foo=bar'
*/
lastpath?: string;
};
}
Contributing
Feel free to suggest features or report bugs and PR's are welcome!
Acknowledgements
This module combines concepts originally introduced by other awesome Open Source projects:
License
Licensed under MIT