Client-side router
Quickly add a description, title, open graph, etc. in your page’s document
head.
This add-on will take care of the nitty-gritty.
Caution
Experimental. This is not well tested, nor it is customizable enough.
Installation
npm i @gracile-labs/client-router
Activate page premises:
📄 ./vite.config.ts
import { defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
, type PluginOptiontype PluginOption = Plugin<any> | {
name: string;
} | FalsyPlugin | PluginOption[] | Promise<Plugin<any> | {
name: string;
} | FalsyPlugin | PluginOption[]>
} from 'vite';
import { gracileimport gracile } from '@gracile/gracile/plugin';
export default defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
({
// ...
pluginsUserConfig.plugins?: PluginOption[] | undefinedArray of vite plugins to use.
: [
gracileimport gracile ({
pagespages: {
premises: {
expose: boolean;
};
}
: { premisespremises: {
expose: boolean;
}
: { exposeexpose: boolean : true } },
}),
// ...
],
});
Usage
After installing the @gracile-labs/client-router, you can create a client
router instance and add it to your document client scripts:
📄 ./src/client-router.ts
import { createRouterimport createRouter } from '@gracile-labs/client-router/create';
As an event target, you can listen to events or navigate programmatically from anywhere.
export const routerconst router: any = createRouterimport createRouter ();
📄 ./src/document.client.ts
import './client-router.js';
📄 ./src/routes/(home).ts
import { routerconst router: any } from '../client-router.js';
// ...
if (!isServer) {
// ...
Trigger as soon as a new URL is requested.
routerconst router: any .addEventListener('route-changed', () => {
this.isSearchBoxVisible = false;
});
Trigger when the route template is fully rendered and displayed.
routerconst router: any .addEventListener('route-rendered', () =>
requestIdleCallbackfunction requestIdleCallback(callback: IdleRequestCallback, options?: IdleRequestOptions): numberThe window.requestIdleCallback() method queues a function to be called during a browser's idle periods. This enables developers to perform background and low priority work on the main thread, without impacting latency-critical events such as animation and input response. Functions are generally called in first-in-first-out order; however, callbacks which have a timeout specified may be called out-of-order if necessary in order to run them before the timeout elapses.
(() => initCardsHover()),
);
setTimeoutfunction setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number (() => {
routerconst router: any .navigate('/docs/');
}, 2000);
}
Script behavior during client-side navigation
When the client router takes over, navigating between routes no longer triggers
a full page reload. This has implications for how <script> elements in the
document <head> are handled.
External scripts (<script type="module" src="…">)
Scripts with a src attribute are reconciled by URL. When navigating to a route
whose document contains a script not yet present on the page, the router
dynamically imports it via import(). Thanks to ESM semantics, modules that
were already loaded are not re-executed — deduplication is free.
This is the normal path for Vite-processed entry points and sibling client scripts. No special handling is needed.
Inline scripts (<script> / <script type="module"> without src)
Design rationale — A user expects an inline script to behave the same whether the page was reached via the browser URL bar (full load) or via client-side navigation. Not re-running inline scripts on a CSR route change would be a surprising deviation from that mental model. Both Turbo Drive and Astro’s
<ClientRouter />take the same stance: inline scripts are re-executed after every navigation.
On every CSR route change, the router:
- Removes all existing inline scripts from
<head>. - Clones each inline script from the incoming document into a fresh
<script>element and appends it to<head>— the browser executes it. - Preserves document order, so dependency chains between scripts work as expected.
This applies to both classic <script> and <script type="module"> without a
src attribute.
Note
Vite typically extracts inline <script type="module"> from your HTML into
external <script src="…"> proxies (in dev and build). True inline module
scripts are uncommon but fully supported if you need them.
Opting out: data-gracile-no-rerun
If an inline script should only run once (e.g. analytics snippets, global
polyfills), add the data-gracile-no-rerun attribute. The router will skip
re-execution for that script on subsequent navigations.
<script data-gracile-no-rerun>
// Runs on initial page load only — skipped during CSR navigation.
initAnalytics();
</script>
Making inline scripts idempotent
When a script does re-run on every navigation, you may want to guard against
duplicate side-effects. A simple pattern using a well-known Symbol:
<script>
const key = Symbol.for('my-widget-init');
if (!window[key]) {
window[key] = true;
// One-time setup…
}
// Per-navigation work…
</script>
This gives you fine-grained control: the guarded block runs once, while everything outside the guard runs on every route change — matching the behavior of a fresh page load followed by in-page updates.