Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/leaner-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'solid-route-progress': minor
---

A leaner bar. `<RouteProgress />` now adds 2363 B min+gzip after tree-shaking (was 2410), `<NavigationProgress />` 2099 B (was 2193), and `style.css` 510 B (was 534).

- The bar slides with the `translate` property instead of `transform`, so `transform` on `.sprogress-bar` is free for your own effects, such as a skew. Its transitions still run on the compositor. If you overrode the bar's `transition` naming `transform`, name `translate` instead.
- The stylesheet holds the short `--sp-speed` hop as the bar's default transition and only the `trickle` state switches to the long drift, so it needs fewer rules. The idle fade only overrides the delays.
- `<html data-sp-busy>` and `aria-busy` are written only when the first bar shows and the last one hides; moves within a load, such as `set()`, no longer rewrite them.
- The default template renders a static element instead of mounting `<Bar />`, so a bar without children mounts no component, spread, or effect for it.
- `<NavigationProgress>` listens with one set of Navigation API listeners instead of two. Behavior is unchanged.
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ pnpm test # Vitest: jsdom, SSR, and real Chromium, Firefox and WebKit
# (once: pnpm exec playwright install chromium firefox webkit)
pnpm typecheck # the whole repo, plus the published entries under isolatedDeclarations
pnpm build # tsdown → dist/*.js (DOM), dist/*.jsx (`solid` condition), d.ts, style.css, docs/*.md
pnpm size # minified gzip/brotli budget, incl. `createProgress` tree-shaken on its own
pnpm size # minified gzip/brotli budget, incl. `createProgress`, `<RouteProgress>` and
# `<NavigationProgress>` each tree-shaken on its own
pnpm check # lint, typecheck, test, build, size
pnpm changeset # describe a change for the next release's notes
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A route progress bar for [SolidJS](https://solidjs.com) and SolidStart: the thin
- Routes, `track(fetch(...))` and your own `start()` each hold the bar, and it completes when the last one lets go.
- Navigations that leave the page (external links, form posts, reloads) show it too, through the Navigation API. `solid-route-progress/navigation` works without a router.
- Styles sit in a cascade layer, so Tailwind v4 utilities win without `!important`, and `data-state` works as a variant. The bar renders on the server and is a labeled `role="progressbar"` that follows `dir="rtl"` and forced colors.
- It weighs about 2.8 kB min+gzip with the router integration and has no dependencies.
- `<RouteProgress />` adds about 2.4 kB min+gzip to your bundle, and there are no dependencies. The bar only animates `opacity` and `translate`, so it stays smooth on the compositor thread while the next route keeps the main thread busy.

## Installation

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "solid-route-progress",
"version": "1.0.6",
"description": "Route progress bar for SolidJS and SolidStart: the thin loading bar along the top of the page. The loading drift is one CSS transition; about 2.8 kB, no dependencies, Tailwind v4 friendly.",
"description": "Route progress bar for SolidJS and SolidStart: the thin loading bar along the top of the page. The loading drift is one CSS transition; about 2.4 kB, no dependencies, Tailwind v4 friendly.",
"license": "MIT",
"author": "kecan0406 (https://github.com/kecan0406)",
"repository": {
Expand Down
2 changes: 2 additions & 0 deletions scripts/size-entries/navigation-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// A consumer that renders only the router-free bar: what `<NavigationProgress />` pays after tree-shaking.
export { NavigationProgress } from '../../src/navigation'
2 changes: 2 additions & 0 deletions scripts/size-entries/route-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// A consumer that renders only the route bar: what the `<RouteProgress />` setup pays after tree-shaking.
export { RouteProgress } from '../../src/router'
2 changes: 2 additions & 0 deletions scripts/size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const BUDGET = {
'shared.js': 2300,
'style.css': 650,
'createProgress-only.js': 1000,
'RouteProgress-only.js': 2500,
'NavigationProgress-only.js': 2250,
}
const JS_TOTAL_BUDGET = 3600

Expand Down
59 changes: 35 additions & 24 deletions src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,23 +118,42 @@ const LOCAL = [
'class',
] as const

/** Bars currently marking the page busy, per attribute: it stays until the last one goes idle. */
const busyBars = new Map<string, Set<object>>()
/** How many bars mark the page busy, per attribute: it stays until the last one goes idle. */
const busy: Record<string, number> = {}

/**
* Mirror `active` onto `<html>` as `attribute`. Only a real change is written, and only by the
* first bar to show or the last to hide, so moves within a load never touch `<html>` (no
* mutation records, no style invalidation for rules keyed on the attribute).
*/
function createBusyAttribute(attribute: string, value: string, active: () => boolean): void {
let bars = busyBars.get(attribute)
if (!bars) busyBars.set(attribute, (bars = new Set()))
const bar = {}
let counted = false
const sync = (on: boolean) => {
if (on) bars.add(bar)
else bars.delete(bar)
if (bars.size) document.documentElement.setAttribute(attribute, value)
else document.documentElement.removeAttribute(attribute)
if (on === counted) return
counted = on
const count = (busy[attribute] = (busy[attribute] ?? 0) + (on ? 1 : -1))
const html = document.documentElement
if (on && count === 1) html.setAttribute(attribute, value)
else if (!count) html.removeAttribute(attribute)
}
createEffect(() => sync(active()))
onCleanup(() => sync(false))
}

/** `base`, plus the classes a caller passed. */
const classes = (base: string, extra: string | undefined) => (extra ? `${base} ${extra}` : base)

/** Development only: say so once the bar mounts laid out but unstyled. */
const checkStylesheet = (root: HTMLElement) =>
onMount(() => {
// No client rects means nothing is laid out (display: none, or a DOM without layout such as jsdom).
if (root.getClientRects().length && getComputedStyle(root).position === 'static')
warn(
"style.css is not loaded: import 'solid-route-progress/style.css' once.",
'installation#stylesheet',
)
})

/**
* The bar shell. Renders a fixed, full-width `role="progressbar"` element and mirrors the
* controller into `--sp-value` / `--sp-speed` / `data-state` / `data-error`; everything
Expand All @@ -144,7 +163,6 @@ export function Progress(props: ProgressProps): JSX.Element {
const [local, options, rest] = splitProps(props, LOCAL, OPTION_KEYS)
// eslint-disable-next-line solid/reactivity -- the controller is picked once, at setup
const controller = useController(local.controller, options)
let root!: HTMLDivElement

// While trickling the target is a guess, so the bar reports as indeterminate.
const valueNow = () =>
Expand All @@ -155,15 +173,6 @@ export function Progress(props: ProgressProps): JSX.Element {
}

if (!isServer) {
if (DEV)
onMount(() => {
// No client rects means nothing is laid out (display: none, or a DOM without layout such as jsdom).
if (root.getClientRects().length && getComputedStyle(root).position === 'static')
warn(
"style.css is not loaded: import 'solid-route-progress/style.css' once.",
'installation#stylesheet',
)
})
createBusyAttribute(
'data-sp-busy',
'',
Expand All @@ -177,7 +186,8 @@ export function Progress(props: ProgressProps): JSX.Element {
<div
// Spread first: the attributes below mirror the controller and must win.
{...rest}
ref={root}
// `undefined` in production builds, so no ref runs there.
ref={DEV ? checkStylesheet : undefined}
role="progressbar"
aria-label={local.label ?? 'Loading'}
aria-valuemin={0}
Expand All @@ -186,22 +196,23 @@ export function Progress(props: ProgressProps): JSX.Element {
aria-valuetext={valueText()}
data-state={controller.state()}
data-error={controller.error() ? '' : undefined}
class={local.class ? `sprogress ${local.class}` : 'sprogress'}
class={classes('sprogress', local.class)}
style={{
...local.style,
'--sp-value': controller.value(),
// Always written, so the CSS transitions run on the same clock as the JS timers.
'--sp-speed': `${controller.options.speed ?? DEFAULTS.speed}ms`,
}}
>
{local.children ?? <Bar />}
{/* What `<Bar />` renders, as a static template: no component, spread or effect. */}
{local.children ?? <div class="sprogress-bar" />}
</div>
</ProgressContext.Provider>
)
}

/** The sliding bar: a full-width strip slid in from the inline-start edge. */
export function Bar(props: ParentProps<JSX.HTMLAttributes<HTMLDivElement>>): JSX.Element {
const [local, rest] = splitProps(props, ['class'])
return <div class={local.class ? `sprogress-bar ${local.class}` : 'sprogress-bar'} {...rest} />
// The class comes after the spread, so it wins over `props.class`, which it already contains.
return <div {...props} class={classes('sprogress-bar', props.class)} />
}
4 changes: 2 additions & 2 deletions src/cross-document.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isServer } from 'solid-js/web'
import { listenCrossDocument, type CrossDocumentOptions } from './engine/cross-document'
import { listenNavigation, type CrossDocumentOptions } from './engine/navigation-api'
import type { ProgressController } from './engine/progress'
import { disposalSignal } from './owner'

Expand All @@ -21,5 +21,5 @@ export function createCrossDocumentProgress(
controller: ProgressController,
options: CrossDocumentOptions = {},
): void {
if (!isServer) listenCrossDocument(controller, options, disposalSignal())
if (!isServer) listenNavigation(controller, options, disposalSignal())
}
76 changes: 0 additions & 76 deletions src/engine/cross-document.ts

This file was deleted.

102 changes: 82 additions & 20 deletions src/engine/navigation-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHandoff, type Outcome, type ProgressController } from './progress'

/**
* Minimal structural typings for the Navigation API (Baseline 2026-01), kept local so the
* library type-checks against any `lib.dom` version.
Expand All @@ -17,26 +19,6 @@ export interface NavigateEventLike extends Event {
export interface NavigationLike extends EventTarget {
/** The intercepted navigation in flight, or `null`. Plain `pushState` / fragment navigations never set it. */
readonly transition: object | null
addEventListener(
type: 'navigate',
listener: (event: NavigateEventLike) => void,
options?: AddEventListenerOptions | boolean,
): void
addEventListener(
type: 'navigateerror',
listener: (event: ErrorEvent) => void,
options?: AddEventListenerOptions | boolean,
): void
addEventListener(
type: 'navigatesuccess' | 'currententrychange',
listener: (event: Event) => void,
options?: AddEventListenerOptions | boolean,
): void
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void
}

/** `window.navigation`, or `undefined` where the Navigation API is unavailable. */
Expand All @@ -49,3 +31,83 @@ export const IGNORE_ATTRIBUTE = 'data-sp-ignore'
/** Whether `target` is, or sits inside, an element marked `data-sp-ignore`. */
export const isIgnored = (target: EventTarget | null | undefined): boolean =>
target instanceof Element && target.closest(`[${IGNORE_ATTRIBUTE}]`) !== null

export interface CrossDocumentOptions {
/**
* Safety net: a cross-document navigation that never unloads the page and is never
* reported as canceled (a `204` response, a server-sent download) completes the bar after
* this many milliseconds. `0` disables it.
* @default 10000
*/
timeout?: number
/** Decide per navigation. Return `false` to keep the bar hidden. */
filter?: (event: NavigateEventLike) => boolean
}

/**
* Hold `controller` for each navigation the page starts, until `signal` aborts: every one that
* leaves the document, and with `sameDocument` also those that stay in it, for pages whose
* router does not report its own. One set of listeners serves both kinds. Does nothing without
* the Navigation API.
*/
export function listenNavigation(
controller: ProgressController,
options: CrossDocumentOptions,
signal: AbortSignal,
sameDocument?: boolean,
): void {
const navigation = getNavigation()
if (!navigation) return
// Leaving and staying navigations hold apart, so a `pushState` never ends a page load.
const cross = createHandoff(controller)
const same = createHandoff(controller)
let timer: ReturnType<typeof setTimeout> | undefined
const leave = (outcome?: Outcome) => {
clearTimeout(timer)
cross.end(outcome)
}
const settle = (outcome?: Outcome) => {
leave(outcome)
same.end(outcome)
}
const listen = <E extends Event>(
target: EventTarget,
type: string,
listener: (event: E) => unknown,
) => target.addEventListener(type, listener as EventListener, { signal })

listen<NavigateEventLike>(navigation, 'navigate', (event) => {
const { url, sameDocument: staying } = event.destination
if (
event.defaultPrevented ||
(staying
? !sameDocument || event.hashChange
: // Downloads, and `mailto:`, `tel:` and friends, fire `navigate` too, yet never unload the document.
event.downloadRequest !== null || !/^https?:/.test(url)) ||
isIgnored(event.sourceElement) ||
options.filter?.(event) === false
)
return
if (staying) return same.next()
cross.next()
clearTimeout(timer)
const timeout = options.timeout ?? 10_000
if (timeout > 0) timer = setTimeout(leave, timeout)
})
// Intercepted, a navigation stays in this document (`sameDocument` is `false` until a router
// intercepts it) and settles through `navigatesuccess` / `navigateerror`, so the safety net
// stands down. Otherwise a same-document one was a plain `pushState`: it committed
// synchronously, never set `transition`, and is over right here.
listen(navigation, 'currententrychange', () =>
navigation.transition ? clearTimeout(timer) : same.end(),
)
listen(navigation, 'navigatesuccess', () => settle())
// A stop or a newer navigation aborts: nothing completed, so fade out instead of running to
// 100%. Anything else is an intercept handler that failed.
listen<ErrorEvent>(navigation, 'navigateerror', (event) =>
settle(event.error?.name === 'AbortError' ? 'cancel' : 'error'),
)
// Back from the bfcache: the load being shown never happened here.
listen<PageTransitionEvent>(window, 'pageshow', (event) => event.persisted && leave('cancel'))
signal.addEventListener('abort', () => settle())
}
Loading
Loading