diff --git a/.changeset/leaner-bar.md b/.changeset/leaner-bar.md new file mode 100644 index 0000000..6337237 --- /dev/null +++ b/.changeset/leaner-bar.md @@ -0,0 +1,11 @@ +--- +'solid-route-progress': minor +--- + +A leaner bar. `` now adds 2363 B min+gzip after tree-shaking (was 2410), `` 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. +- `` 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 ``, so a bar without children mounts no component, spread, or effect for it. +- `` listens with one set of Navigation API listeners instead of two. Behavior is unchanged. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d2410b..c7db27e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`, `` and + # `` each tree-shaken on its own pnpm check # lint, typecheck, test, build, size pnpm changeset # describe a change for the next release's notes ``` diff --git a/README.md b/README.md index 2fdf2a5..b1e1ec0 100644 --- a/README.md +++ b/README.md @@ -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. +- `` 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 diff --git a/package.json b/package.json index 625b3cb..dfd0385 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/size-entries/navigation-progress.ts b/scripts/size-entries/navigation-progress.ts new file mode 100644 index 0000000..5b0e6e1 --- /dev/null +++ b/scripts/size-entries/navigation-progress.ts @@ -0,0 +1,2 @@ +// A consumer that renders only the router-free bar: what `` pays after tree-shaking. +export { NavigationProgress } from '../../src/navigation' diff --git a/scripts/size-entries/route-progress.ts b/scripts/size-entries/route-progress.ts new file mode 100644 index 0000000..4e2ac40 --- /dev/null +++ b/scripts/size-entries/route-progress.ts @@ -0,0 +1,2 @@ +// A consumer that renders only the route bar: what the `` setup pays after tree-shaking. +export { RouteProgress } from '../../src/router' diff --git a/scripts/size.mjs b/scripts/size.mjs index ea5d1ed..90ee6c7 100644 --- a/scripts/size.mjs +++ b/scripts/size.mjs @@ -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 diff --git a/src/components.tsx b/src/components.tsx index 602c378..30ba730 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -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>() +/** How many bars mark the page busy, per attribute: it stays until the last one goes idle. */ +const busy: Record = {} +/** + * Mirror `active` onto `` 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 `` (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 @@ -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 = () => @@ -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', '', @@ -177,7 +186,8 @@ export function Progress(props: ProgressProps): JSX.Element {
- {local.children ?? } + {/* What `` renders, as a static template: no component, spread or effect. */} + {local.children ??
}
) @@ -202,6 +213,6 @@ export function Progress(props: ProgressProps): JSX.Element { /** The sliding bar: a full-width strip slid in from the inline-start edge. */ export function Bar(props: ParentProps>): JSX.Element { - const [local, rest] = splitProps(props, ['class']) - return
+ // The class comes after the spread, so it wins over `props.class`, which it already contains. + return
} diff --git a/src/cross-document.ts b/src/cross-document.ts index 90455ef..376e49c 100644 --- a/src/cross-document.ts +++ b/src/cross-document.ts @@ -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' @@ -21,5 +21,5 @@ export function createCrossDocumentProgress( controller: ProgressController, options: CrossDocumentOptions = {}, ): void { - if (!isServer) listenCrossDocument(controller, options, disposalSignal()) + if (!isServer) listenNavigation(controller, options, disposalSignal()) } diff --git a/src/engine/cross-document.ts b/src/engine/cross-document.ts deleted file mode 100644 index 44c3e46..0000000 --- a/src/engine/cross-document.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { getNavigation, isIgnored, type NavigateEventLike } from './navigation-api' -import { createHandoff, type Outcome, type ProgressController } from './progress' - -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 -} - -/** - * The listeners behind `createCrossDocumentProgress()`: hold `controller` for each navigation - * that leaves the document, until `signal` aborts. Does nothing without the Navigation API. - */ -export function listenCrossDocument( - controller: ProgressController, - options: CrossDocumentOptions, - signal: AbortSignal, -): void { - const navigation = getNavigation() - if (!navigation) return - let timer: ReturnType | undefined - /** Holds the navigation still in flight. */ - const hold = createHandoff(controller) - - const finish = (outcome?: Outcome) => { - clearTimeout(timer) - hold.end(outcome) - } - - navigation.addEventListener( - 'navigate', - (event) => { - if ( - event.defaultPrevented || - event.destination.sameDocument || - event.downloadRequest !== null || - // `mailto:`, `tel:` and friends fire `navigate` too, yet never unload the document. - !/^https?:/.test(event.destination.url) || - isIgnored(event.sourceElement) || - options.filter?.(event) === false - ) - return - hold.next() - clearTimeout(timer) - const timeout = options.timeout ?? 10_000 - if (timeout > 0) timer = setTimeout(finish, timeout) - }, - { signal }, - ) - // A router intercepted it after all (`sameDocument` is `false` until someone does): it stays - // in this document, so let `navigatesuccess` / `navigateerror` end it, not the safety net. - navigation.addEventListener( - 'currententrychange', - () => navigation.transition && clearTimeout(timer), - { - signal, - }, - ) - navigation.addEventListener('navigatesuccess', () => finish(), { signal }) - // 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. - navigation.addEventListener( - 'navigateerror', - (event) => finish(event.error?.name === 'AbortError' ? 'cancel' : 'error'), - { signal }, - ) - // Back from the bfcache: the load being shown never happened here. - window.addEventListener('pageshow', (event) => event.persisted && finish('cancel'), { signal }) - signal.addEventListener('abort', () => finish()) -} diff --git a/src/engine/navigation-api.ts b/src/engine/navigation-api.ts index 6d314ef..ccd667b 100644 --- a/src/engine/navigation-api.ts +++ b/src/engine/navigation-api.ts @@ -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. @@ -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. */ @@ -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 | undefined + const leave = (outcome?: Outcome) => { + clearTimeout(timer) + cross.end(outcome) + } + const settle = (outcome?: Outcome) => { + leave(outcome) + same.end(outcome) + } + const listen = ( + target: EventTarget, + type: string, + listener: (event: E) => unknown, + ) => target.addEventListener(type, listener as EventListener, { signal }) + + listen(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(navigation, 'navigateerror', (event) => + settle(event.error?.name === 'AbortError' ? 'cancel' : 'error'), + ) + // Back from the bfcache: the load being shown never happened here. + listen(window, 'pageshow', (event) => event.persisted && leave('cancel')) + signal.addEventListener('abort', () => settle()) +} diff --git a/src/engine/progress.ts b/src/engine/progress.ts index 8bcb508..9ea644c 100644 --- a/src/engine/progress.ts +++ b/src/engine/progress.ts @@ -146,6 +146,9 @@ export const DEFAULTS: Required = { speed: 200, } +// `ReturnType` so the same code type-checks under DOM and Node libs. +type Timer = ReturnType | undefined + /** * Headless progress state machine. Rendering is left to CSS: the controller only exposes a * target `value` and a `state`, which a bar mirrors to `--sp-value` and `data-state`. Its @@ -161,45 +164,43 @@ export function createProgressEngine( const [error, setError] = cell(false) const opt = (key: K): (typeof DEFAULTS)[K] => options[key] ?? DEFAULTS[key] - const holds = new Set() + /** The release of every hold still open: each one is its own key. */ + const holds = new Set() - // Timers are `ReturnType` so the same code type-checks under DOM and Node libs. /** The one scheduled step: reveal while `idle`, resume trickling while `active`, hide while `done`. */ - let timer: ReturnType | undefined - let stopTimer: ReturnType | undefined + let timer: Timer + let stopTimer: Timer /** `true` once the browser has had a chance to paint the visible bar. */ let painted = false /** A hold was released as an `'error'` during the current load. */ let failed = false - const schedule = (step: () => void, ms: number) => { - clearTimeout(timer) - timer = setTimeout(() => { - timer = undefined - step() - }, ms) - } - const cancel = () => { + /** Replace the scheduled step with `step` in `ms`, or with nothing. */ + const schedule = (step?: () => void, ms?: number) => { clearTimeout(timer) - timer = undefined + timer = + step && + setTimeout(() => { + timer = undefined + step() + }, ms) } /** A reveal is scheduled: the load has not lasted `delay` yet. */ const pending = () => state() === 'idle' && timer !== undefined - const move = (s: ProgressState, v: number) => + /** One update for everything a bar mirrors. The error mark only ever shows while `done`. */ + const move = (s: ProgressState, v: number, e = false) => batch(() => { setState(s) setValue(v) + setError(e) }) const hide = () => { failed = false - batch(() => { - setError(false) - move('idle', 0) - }) + move('idle', 0) } const drop = () => { - cancel() + schedule() hide() } const reveal = (s: ProgressState, v: number) => { @@ -212,39 +213,37 @@ export function createProgressEngine( const finish = () => { // Nothing was ever painted: drop the bar silently instead of flashing it. if (!painted) return drop() - batch(() => { - setError(failed) - move('done', 1) - }) + move('done', 1, failed) schedule(hide, opt('speed')) } - /** Every hold is gone: complete or drop the bar, or cancel a reveal that is not due yet. */ - const settle = (canceled: boolean) => { + /** + * A hold ended, or `done()` dropped them all. Once none is left, complete or drop the bar, or + * cancel a reveal that is not due yet. + */ + const end = (outcome?: Outcome) => { + if (outcome === 'error') failed = true + if (holds.size) return const s = state() - if (s === 'trickle' || s === 'active') { - const end = canceled && !failed ? drop : finish - const stopDelay = opt('stopDelay') - clearTimeout(stopTimer) - if (stopDelay > 0) stopTimer = setTimeout(end, stopDelay) - else end() - } else if (s === 'idle') { + if (s === 'idle') { // Nothing shown, and nothing left to report. - cancel() + schedule() failed = false + } else if (s !== 'done') { + const stop = outcome === 'cancel' && !failed ? drop : finish + const stopDelay = opt('stopDelay') + clearTimeout(stopTimer) + if (stopDelay > 0) stopTimer = setTimeout(stop, stopDelay) + else stop() } } - const release = (hold: object, outcome?: Outcome) => { - if (!holds.delete(hold)) return - if (outcome === 'error') failed = true - if (!holds.size) settle(outcome === 'cancel') - } - const start = (): Release => { - const hold = {} + const release = ((outcome?: Outcome) => { + if (holds.delete(release)) end(outcome) + }) as Release if (!server) { - holds.add(hold) + holds.add(release) // A pending stop is superseded by the new load. clearTimeout(stopTimer) const s = state() @@ -260,15 +259,13 @@ export function createProgressEngine( else show() } } - const releaseHold = (outcome?: Outcome) => release(hold, outcome) - if (dispose) (releaseHold as unknown as Record void>)[dispose] = releaseHold - return releaseHold as Release + if (dispose) (release as unknown as Record 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