void>)[dispose] = release
+ return release
}
const done = (outcome?: Outcome) => {
holds.clear()
- if (outcome === 'error') failed = true
- settle(outcome === 'cancel')
+ end(outcome)
}
const set = (n: number) => {
@@ -284,17 +281,17 @@ export function createProgressEngine(
// value already sits past the trickle target, in which case hold there.
const trickleTo = opt('trickleTo')
if (n < trickleTo) schedule(() => move('trickle', trickleTo), opt('speed'))
- else cancel()
+ else schedule()
}
const track = >(promise: P, options?: TrackOptions): P => {
- const releaseHold = start()
+ const release = start()
const timeout = options?.timeout
- const timer = timeout && !server ? setTimeout(releaseHold, timeout) : undefined
+ const timer = timeout && !server ? setTimeout(release, timeout) : undefined
promise
.then(
- () => releaseHold(),
- () => releaseHold('error'),
+ () => release(),
+ () => release('error'),
)
.then(() => clearTimeout(timer))
return promise
diff --git a/src/engine/same-document.ts b/src/engine/same-document.ts
deleted file mode 100644
index 9b7b353..0000000
--- a/src/engine/same-document.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { CrossDocumentOptions } from './cross-document'
-import { getNavigation, isIgnored } from './navigation-api'
-import { createHandoff, type ProgressController } from './progress'
-
-/**
- * The same-document half of `createNavigationProgress()`: hold `controller` for each
- * navigation that stays in the document, until `signal` aborts. Does nothing without the
- * Navigation API.
- */
-export function listenSameDocument(
- controller: ProgressController,
- options: CrossDocumentOptions,
- signal: AbortSignal,
-): void {
- const navigation = getNavigation()
- if (!navigation) return
- const hold = createHandoff(controller)
-
- navigation.addEventListener(
- 'navigate',
- (event) => {
- if (
- event.defaultPrevented ||
- !event.destination.sameDocument ||
- event.hashChange ||
- isIgnored(event.sourceElement) ||
- options.filter?.(event) === false
- )
- return
- hold.next()
- },
- { signal },
- )
- // Only intercepted navigations settle through `navigatesuccess` / `navigateerror`. A plain
- // `pushState` commits synchronously, never sets `transition`, and is over right here.
- navigation.addEventListener('currententrychange', () => navigation.transition || hold.end(), {
- signal,
- })
- navigation.addEventListener('navigatesuccess', () => hold.end(), { signal })
- // An abort (a newer navigation, a stop) is not a failure; a rejected handler is.
- navigation.addEventListener(
- 'navigateerror',
- (event) => hold.end(event.error?.name === 'AbortError' ? 'cancel' : 'error'),
- { signal },
- )
- signal.addEventListener('abort', () => hold.end())
-}
diff --git a/src/index.ts b/src/index.ts
index 30b0288..01fd6e8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,5 @@ export type {
export { Bar, Progress, ProgressContext, ProgressProvider, useProgress } from './components'
export type { ProgressProps, ProgressProviderProps } from './components'
export { createCrossDocumentProgress } from './cross-document'
-export type { CrossDocumentOptions } from './engine/cross-document'
export { IGNORE_ATTRIBUTE } from './engine/navigation-api'
-export type { NavigateEventLike } from './engine/navigation-api'
+export type { CrossDocumentOptions, NavigateEventLike } from './engine/navigation-api'
diff --git a/src/navigation.tsx b/src/navigation.tsx
index a54b545..5e80db3 100644
--- a/src/navigation.tsx
+++ b/src/navigation.tsx
@@ -1,12 +1,9 @@
import { splitProps, type JSX } from 'solid-js'
import { isServer } from 'solid-js/web'
import { OPTION_KEYS, Progress, useController, type ProgressProps } from './components'
-import { createCrossDocumentProgress } from './cross-document'
import { DEV, warn } from './dev'
-import type { CrossDocumentOptions } from './engine/cross-document'
-import { getNavigation } from './engine/navigation-api'
+import { getNavigation, listenNavigation, type CrossDocumentOptions } from './engine/navigation-api'
import type { ProgressController } from './engine/progress'
-import { listenSameDocument } from './engine/same-document'
import { disposalSignal } from './owner'
export type NavigationProgressOptions = CrossDocumentOptions
@@ -28,8 +25,7 @@ export function createNavigationProgress(
'Navigation API unavailable: NavigationProgress shows nothing in this browser.',
'navigation-api#where-the-api-is-missing',
)
- createCrossDocumentProgress(controller, options)
- listenSameDocument(controller, options, disposalSignal())
+ listenNavigation(controller, options, disposalSignal(), true)
}
export interface NavigationProgressProps extends ProgressProps, NavigationProgressOptions {}
diff --git a/src/router.tsx b/src/router.tsx
index fa9613a..83509c3 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -2,10 +2,8 @@ import { createEffect, on, onCleanup, splitProps, type JSX } from 'solid-js'
import { isServer } from 'solid-js/web'
import { useBeforeLeave, useIsRouting, type Location } from '@solidjs/router'
import { OPTION_KEYS, Progress, useController, type ProgressProps } from './components'
-import { createCrossDocumentProgress } from './cross-document'
import { DEV, explain } from './dev'
-import type { CrossDocumentOptions } from './engine/cross-document'
-import { isIgnored } from './engine/navigation-api'
+import { isIgnored, listenNavigation, type CrossDocumentOptions } from './engine/navigation-api'
import { createHandoff, type ProgressController } from './engine/progress'
import { disposalSignal } from './owner'
@@ -42,6 +40,7 @@ export function createRouteProgress(
): void {
if (isServer) return
const isRouting = DEV ? explainedIsRouting() : useIsRouting()
+ const signal = disposalSignal()
let skip = false
const skipNext = () => {
skip = true
@@ -58,7 +57,7 @@ export function createRouteProgress(
(event) => {
if (isIgnored(event.target)) skipNext()
},
- { capture: true, signal: disposalSignal() },
+ { capture: true, signal },
)
useBeforeLeave((event) => {
@@ -83,9 +82,10 @@ export function createRouteProgress(
onCleanup(() => hold.end())
if (options.crossDocument !== false)
- createCrossDocumentProgress(
+ listenNavigation(
controller,
- typeof options.crossDocument === 'object' ? options.crossDocument : undefined,
+ typeof options.crossDocument === 'object' ? options.crossDocument : {},
+ signal,
)
}
@@ -104,11 +104,10 @@ function explainedIsRouting() {
}
}
-const samePathname = (to: string, pathname: string) => {
- const end = to.search(/[?#]/)
- const target = end === -1 ? to : to.slice(0, end)
- return target.replace(/\/+$/, '') === pathname.replace(/\/+$/, '')
-}
+/** The pathname alone: no trailing slashes, no search string, no hash. */
+const bare = (path: string) => path.replace(/\/*([?#].*)?$/, '')
+
+const samePathname = (to: string, pathname: string) => bare(to) === bare(pathname)
export interface RouteProgressProps extends ProgressProps, RouteProgressOptions {}
diff --git a/src/style.css b/src/style.css
index 35be8a6..352674d 100644
--- a/src/style.css
+++ b/src/style.css
@@ -13,7 +13,8 @@
* your own rules, set it through `speed`.
*
* Everything sits in the `components.sprogress` sublayer, so Tailwind utilities, your own
- * `@layer components` rules and unlayered CSS all win without `!important`.
+ * `@layer components` rules and unlayered CSS all win without `!important`. Motion is
+ * `opacity` and `translate` only, so it runs on the compositor even while the page is busy.
*/
@property --sp-value {
@@ -25,54 +26,53 @@
@layer components.sprogress {
.sprogress {
position: fixed;
- inset: 0 0 auto 0;
+ inset: 0 0 auto;
z-index: var(--sp-z-index, 9999);
height: var(--sp-height, 3px);
pointer-events: none;
visibility: visible;
transition:
- opacity var(--sp-speed) ease,
+ opacity var(--sp-speed),
visibility 0s;
}
.sprogress[data-state='idle'] {
opacity: 0;
visibility: hidden;
- /* keep painting while the opacity fades, then drop out of the a11y tree / hit-testing */
- transition:
- opacity var(--sp-speed) ease,
- visibility 0s var(--sp-speed);
+ /* keep painting while the opacity fades, then drop out of the a11y tree / hit-testing. A
+ delayed 0s step, not a timed one: the main thread sleeps through the fade */
+ transition-delay: 0s, var(--sp-speed);
}
.sprogress-bar {
position: absolute;
inset: 0;
background: var(--sp-color, oklch(0.65 0.14 241));
- /* full-width bar slid in from the inline-start edge */
- transform: translateX(calc((var(--sp-value) - 1) * 100%));
- /* fast out of the gate, then a long crawl — never quite arriving. The curve is a longhand
- so a browser without linear() loses only the curve (falling back to ease), not the drift */
- transition: transform var(--sp-trickle-duration, 10s);
+ /* full-width bar slid in from the inline-start edge; `translate` leaves `transform` to you */
+ translate: calc((var(--sp-value) - 1) * 100%);
+ /* set() / done(): a short, eased hop */
+ transition: translate var(--sp-speed);
+ }
+
+ .sprogress:dir(rtl) .sprogress-bar {
+ translate: calc((1 - var(--sp-value)) * 100%);
+ }
+
+ /* loading: fast out of the gate, then a long crawl — never quite arriving. Longhands, so a
+ browser without linear() loses only the curve (falling back to ease), not the drift */
+ .sprogress[data-state='trickle'] .sprogress-bar {
+ transition-duration: var(--sp-trickle-duration, 10s);
transition-timing-function: var(
--sp-trickle-easing,
linear(0, 0.25 5%, 0.4 10%, 0.55 18%, 0.7 30%, 0.8 45%, 0.88 60%, 0.94 75%, 0.98 90%, 1)
);
}
- .sprogress:dir(rtl) .sprogress-bar {
- transform: translateX(calc((1 - var(--sp-value)) * 100%));
- }
-
/* hidden: park the bar at the start position, but only after the fade-out finished */
.sprogress[data-state='idle'] .sprogress-bar {
--sp-value: var(--sp-start, 0.08);
- transition: transform 0s var(--sp-speed);
- }
-
- /* explicit set() / done(): short, eased hop instead of the long drift */
- .sprogress:is([data-state='active'], [data-state='done']) .sprogress-bar {
- transition-duration: var(--sp-speed);
- transition-timing-function: ease;
+ transition-duration: 0s;
+ transition-delay: var(--sp-speed);
}
/* forced colors strip backgrounds; paint the bar with the system highlight instead */
diff --git a/test/browser/style.test.tsx b/test/browser/style.test.tsx
index aa906a3..0172bb9 100644
--- a/test/browser/style.test.tsx
+++ b/test/browser/style.test.tsx
@@ -7,8 +7,9 @@ import '../../src/style.css'
/** The JS ↔ CSS contract: `data-state` / `--sp-value` / `--sp-speed` must drive the stylesheet. */
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()))
-/** Horizontal translation of the bar in px. */
-const tx = (el: Element) => new DOMMatrix(getComputedStyle(el).transform).m41
+/** Horizontal offset of the bar from its root in px, as drawn, whichever property moves it. */
+const tx = (el: Element) =>
+ el.getBoundingClientRect().left - el.parentElement!.getBoundingClientRect().left
let dispose = () => {}
afterEach(() => {
diff --git a/tsdown.size.config.ts b/tsdown.size.config.ts
index dd04129..b7729cc 100644
--- a/tsdown.size.config.ts
+++ b/tsdown.size.config.ts
@@ -32,10 +32,16 @@ export default defineConfig([
outDir: '.size',
outputOptions: { chunkFileNames: 'shared.js' },
},
- // One import at a time, tree-shaken on its own: catches the core dragging in the components.
- {
+ // One import at a time, tree-shaken on its own: catches the core dragging in the components,
+ // and measures what the two drop-in bars really cost.
+ ...Object.entries({
+ 'createProgress-only': 'scripts/size-entries/create-progress.ts',
+ 'RouteProgress-only': 'scripts/size-entries/route-progress.ts',
+ 'NavigationProgress-only': 'scripts/size-entries/navigation-progress.ts',
+ }).map(([name, file]): UserConfig => ({
...production,
- entry: { 'createProgress-only': 'scripts/size-entries/create-progress.ts' },
+ entry: { [name]: file },
outDir: '.size-exports',
- },
+ clean: false,
+ })),
])
diff --git a/www/src/routes/docs/index.mdx b/www/src/routes/docs/index.mdx
index c964e1a..f906db7 100644
--- a/www/src/routes/docs/index.mdx
+++ b/www/src/routes/docs/index.mdx
@@ -39,22 +39,23 @@ Everything else is CSS.
## How it works
-1. `start()` waits `delay` (200 ms), then flips `data-state` to `trickle` and sets `--sp-value` to `trickleTo` (0.95). The stylesheet's `trickle` rule has a 10 s transition on `transform` whose curve races out and then crawls. It is one transition, and no timer steps it.
-2. `set(n)` switches to the `active` rule (short `--sp-speed` transition) for the hop, then hands back to `trickle`. CSS transitions interrupt from the _current_ animated value, so there is nothing to sync.
-3. Once the last hold is released (or on `done()`), the bar moves to 100% under the `done` rule (with `data-error` if a hold was released as an `'error'`; a `'cancel'` skips this step and fades straight out), then `idle` fades the whole bar out with `opacity` + a delayed `visibility: hidden`. The bar itself is parked back at `--sp-start` only after the fade has finished. Both steps take `speed`, which the bar also writes to `--sp-speed`, so the CSS and the timers never disagree. A load that starts during this phase waits for the fade and for `delay`, whichever is longer.
+1. `start()` waits `delay` (200 ms), then flips `data-state` to `trickle` and sets `--sp-value` to `trickleTo` (0.95). The stylesheet's `trickle` rule has a 10 s transition on `translate` whose curve races out and then crawls. It is one transition, and no timer steps it.
+2. `set(n)` switches to `active`, which leaves the bar on its short `--sp-speed` transition for the hop, then hands back to `trickle`. CSS transitions interrupt from the _current_ animated value, so there is nothing to sync.
+3. Once the last hold is released (or on `done()`), the bar hops to 100% in the `done` state (with `data-error` if a hold was released as an `'error'`; a `'cancel'` skips this step and fades straight out), then `idle` fades the whole bar out with `opacity` + a delayed `visibility: hidden`. The bar itself is parked back at `--sp-start` only after the fade has finished. Both steps take `speed`, which the bar also writes to `--sp-speed`, so the CSS and the timers never disagree. A load that starts during this phase waits for the fade and for `delay`, whichever is longer.
4. If the load ends while `delay` is still pending, or before the browser painted the bar (tracked with a single `requestAnimationFrame`), the bar is dropped silently.
## Size
-| Module | min | gzip | brotli |
-| --------------------------------- | ---- | ---- | ------ |
-| `solid-route-progress` | 4676 | 2143 | 1949 |
-| `createProgress` alone | 1589 | 823 | 762 |
-| `solid-route-progress/router` | 1025 | 610 | 524 |
-| `solid-route-progress/navigation` | 933 | 503 | 425 |
-| `solid-route-progress/style.css` | 1167 | 531 | 437 |
+What each setup adds to your bundle, in bytes, after tree-shaking:
-Zero dependencies.
+| Import | min | gzip | brotli |
+| ----------------------------------------------------------- | ---- | ---- | ------ |
+| ``, `solid-route-progress/router` | 4838 | 2363 | 2135 |
+| ``, `solid-route-progress/navigation` | 4300 | 2099 | 1905 |
+| `createProgress` alone | 1721 | 908 | 841 |
+| `solid-route-progress/style.css` | 1089 | 510 | 418 |
+
+Zero dependencies. The bar only moves `opacity` and `translate`, so its animations run on the compositor thread and stay smooth while the next route keeps the main thread busy.
## Next