From 63b33751c9e916198170b31f5af3808c6aaee4bf Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 09:03:42 -0300
Subject: [PATCH 01/11] Initial commit of favicon package
---
.changeset/favicon-initial.md | 35 ++
packages/favicon/LICENSE | 21 +
packages/favicon/README.md | 432 ++++++++++++++++++
packages/favicon/package.json | 79 ++++
packages/favicon/src/animation.ts | 213 +++++++++
packages/favicon/src/badge.ts | 127 +++++
packages/favicon/src/canvas.ts | 97 ++++
packages/favicon/src/components.tsx | 27 ++
packages/favicon/src/favicon.ts | 70 +++
packages/favicon/src/index.ts | 28 ++
packages/favicon/src/link.ts | 52 +++
packages/favicon/src/progress.ts | 103 +++++
packages/favicon/src/scheme.ts | 120 +++++
packages/favicon/stories/_helpers.tsx | 29 ++
.../favicon/stories/createFavicon.stories.tsx | 61 +++
.../createFaviconAnimation.stories.tsx | 60 +++
.../stories/createFaviconBadge.stories.tsx | 51 +++
.../stories/createFaviconProgress.stories.tsx | 112 +++++
.../stories/createFaviconScheme.stories.tsx | 56 +++
packages/favicon/stories/tsconfig.json | 4 +
packages/favicon/test/animation.test.ts | 203 ++++++++
packages/favicon/test/badge.test.ts | 183 ++++++++
packages/favicon/test/components.test.tsx | 62 +++
packages/favicon/test/favicon.test.ts | 98 ++++
packages/favicon/test/progress.test.ts | 141 ++++++
packages/favicon/test/scheme.test.ts | 96 ++++
packages/favicon/test/server.test.tsx | 105 +++++
packages/favicon/test/setup.ts | 151 ++++++
packages/favicon/tsconfig.json | 4 +
patches/storybook-solidjs-vite@10.5.2.patch | 27 ++
pnpm-lock.yaml | 23 +-
pnpm-workspace.yaml | 3 +
template/stories/tsconfig.json | 2 +-
33 files changed, 2872 insertions(+), 3 deletions(-)
create mode 100644 .changeset/favicon-initial.md
create mode 100644 packages/favicon/LICENSE
create mode 100644 packages/favicon/README.md
create mode 100644 packages/favicon/package.json
create mode 100644 packages/favicon/src/animation.ts
create mode 100644 packages/favicon/src/badge.ts
create mode 100644 packages/favicon/src/canvas.ts
create mode 100644 packages/favicon/src/components.tsx
create mode 100644 packages/favicon/src/favicon.ts
create mode 100644 packages/favicon/src/index.ts
create mode 100644 packages/favicon/src/link.ts
create mode 100644 packages/favicon/src/progress.ts
create mode 100644 packages/favicon/src/scheme.ts
create mode 100644 packages/favicon/stories/_helpers.tsx
create mode 100644 packages/favicon/stories/createFavicon.stories.tsx
create mode 100644 packages/favicon/stories/createFaviconAnimation.stories.tsx
create mode 100644 packages/favicon/stories/createFaviconBadge.stories.tsx
create mode 100644 packages/favicon/stories/createFaviconProgress.stories.tsx
create mode 100644 packages/favicon/stories/createFaviconScheme.stories.tsx
create mode 100644 packages/favicon/stories/tsconfig.json
create mode 100644 packages/favicon/test/animation.test.ts
create mode 100644 packages/favicon/test/badge.test.ts
create mode 100644 packages/favicon/test/components.test.tsx
create mode 100644 packages/favicon/test/favicon.test.ts
create mode 100644 packages/favicon/test/progress.test.ts
create mode 100644 packages/favicon/test/scheme.test.ts
create mode 100644 packages/favicon/test/server.test.tsx
create mode 100644 packages/favicon/test/setup.ts
create mode 100644 packages/favicon/tsconfig.json
create mode 100644 patches/storybook-solidjs-vite@10.5.2.patch
diff --git a/.changeset/favicon-initial.md b/.changeset/favicon-initial.md
new file mode 100644
index 000000000..3597465d0
--- /dev/null
+++ b/.changeset/favicon-initial.md
@@ -0,0 +1,35 @@
+---
+"@solid-primitives/favicon": major
+---
+
+New package: `@solid-primitives/favicon`
+
+Primitives for controlling the document favicon, built for Solid 2.0 (beta.24).
+
+### `makeFavicon` / `createFavicon`
+
+Non-reactive base and reactive primitive for swapping the favicon `` href. Reuses an existing `` if present and restores its previous href on `dispose()`/cleanup; creates and removes one otherwise. Nested/sequential calls compose correctly under normal mount/unmount ordering.
+
+### `makeFaviconAnimation` / `createFaviconAnimation`
+
+Cycles the favicon through a sequence of hrefs on an interval — for build-status spinners and similar loading indicators. The reactive version accepts a static or reactive frame list, exposes `frame`/`playing` signals and `play`/`pause`, and automatically pauses while the tab is hidden (`document.visibilitychange`), resuming if it was playing before.
+
+### `makeFaviconBadge` / `createFaviconBadge`
+
+Composites a notification count, short string, or plain dot onto a base icon via an offscreen canvas. `0`/`false`/`undefined`/`""` render no badge; `true` renders a dot; numbers clamp to `"{max}+"`. Falls back to the plain base href if the base image fails to load.
+
+### `makeFaviconScheme` / `createFaviconScheme`
+
+Swaps between a light/dark icon to match `prefers-color-scheme`, staying in sync via a `matchMedia` listener as the OS/browser preference changes. Treats SSR as `"light"` (no reliable server-side signal for OS theme).
+
+### `makeFaviconProgress` / `createFaviconProgress`
+
+Composites a progress ring (0–100, clamped) onto a base icon via an offscreen canvas — for upload/download-style indicators. `undefined` shows the base icon with no ring; `0` still draws the (empty) ring track. Falls back to the plain base href if the base image fails to load.
+
+### `FaviconLink`
+
+A component that renders the favicon `` directly, so its initial href is part of the server-rendered HTML instead of only applying once client JS hydrates. Place it once in your document's real `
` region (e.g. your SSR framework's root document component); any `make*`/`create*` call elsewhere composes with it automatically, since `bindFaviconLink`'s existing "reuse an existing link" lookup finds and takes over the exact element it rendered.
+
+### Design notes
+
+Rather than one `createAdvancedFavicon` combining all of this behind an options object, each capability is its own small `make*`/`create*` pair sharing internal DOM (`link.ts`) and canvas (`canvas.ts`) helpers — consistent with this repo's existing layered-primitive packages (e.g. `video`). See [DESIGN.md](../packages/favicon/DESIGN.md) for the full reasoning.
diff --git a/packages/favicon/LICENSE b/packages/favicon/LICENSE
new file mode 100644
index 000000000..38b41d975
--- /dev/null
+++ b/packages/favicon/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/favicon/README.md b/packages/favicon/README.md
new file mode 100644
index 000000000..39c24697e
--- /dev/null
+++ b/packages/favicon/README.md
@@ -0,0 +1,432 @@
+
+
+
+
+# @solid-primitives/favicon
+
+[](https://bundlephobia.com/package/@solid-primitives/favicon)
+[](https://www.npmjs.com/package/@solid-primitives/favicon)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+[](https://vitest.dev)
+
+Primitives for controlling the document favicon: swap it, animate it, overlay a notification
+badge, follow the OS color scheme, or show upload/download progress. The `make*` variants are
+non-reactive and require no Solid owner. The `create*` variants integrate with Solid's reactive
+system and restore the previous favicon `onCleanup`.
+
+- `makeFavicon` / `createFavicon` — swap the favicon href.
+- `makeFaviconAnimation` / `createFaviconAnimation` — cycle a sequence of hrefs on an interval, for
+ loading spinners and status indicators.
+- `makeFaviconBadge` / `createFaviconBadge` — composite a notification count/dot onto a base icon.
+- `makeFaviconScheme` / `createFaviconScheme` — swap between a light/dark icon to match
+ `prefers-color-scheme`.
+- `makeFaviconProgress` / `createFaviconProgress` — composite a progress ring onto a base icon.
+- `FaviconLink` — a Solid component that renders the favicon `` directly, so its initial
+ href is part of the server-rendered HTML instead of only applying once the client hydrates.
+
+## Installation
+
+```bash
+npm install @solid-primitives/favicon
+# or
+yarn add @solid-primitives/favicon
+# or
+pnpm add @solid-primitives/favicon
+```
+
+## How to use it
+
+### `makeFavicon` / `createFavicon`
+
+```ts
+const favicon = makeFavicon("/favicon-dark.svg");
+favicon.setHref("/favicon-light.svg");
+favicon.dispose(); // restores whatever favicon was there before
+```
+
+```ts
+const href = createFavicon(() => (unreadCount() > 0 ? "/favicon-alert.svg" : "/favicon.svg"));
+href(); // the currently-applied href, resolved to an absolute URL
+```
+
+```ts
+type FaviconRel = "icon" | "shortcut icon" | "apple-touch-icon";
+
+type FaviconOptions = {
+ /** @default "icon" */
+ rel?: FaviconRel;
+};
+
+type FaviconController = {
+ readonly href: string;
+ setHref(href: string): void;
+ dispose(): void;
+};
+
+function makeFavicon(href: string, options?: FaviconOptions): FaviconController;
+
+function createFavicon(href: MaybeAccessor, options?: FaviconOptions): Accessor;
+```
+
+If the page already has a ``, it's reused and its href restored on `dispose()`;
+otherwise one is created and removed on `dispose()`. Nested/sequential calls compose correctly as
+long as disposal happens in the reverse of creation order (the normal case for component
+mount/unmount) — each call restores whatever was there right before it ran, like a stack.
+
+### `makeFaviconAnimation` / `createFaviconAnimation`
+
+For build-status spinners, "recording" pulses, and similar loading indicators — cycles the
+favicon through a sequence of pre-rendered frames.
+
+```ts
+const anim = makeFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"], {
+ interval: 150,
+});
+anim.pause();
+anim.play();
+anim.dispose(); // restores the previous favicon, stops the interval
+```
+
+```ts
+const spinner = createFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"]);
+spinner.frame(); // current frame index
+spinner.playing(); // boolean
+spinner.pause();
+spinner.play();
+```
+
+The reactive `createFaviconAnimation` automatically pauses while the tab is hidden
+(`document.visibilitychange`) and resumes if it was playing before — a backgrounded tab won't keep
+repainting an icon nobody can see.
+
+```ts
+type FaviconAnimationOptions = {
+ /** Milliseconds between frames. @default 200 */
+ interval?: number;
+ /** Start cycling immediately. @default true */
+ autoplay?: boolean;
+ /** Loop back to the first frame after the last. @default true */
+ loop?: boolean;
+} & FaviconOptions;
+
+type FaviconAnimationController = {
+ readonly href: string;
+ readonly frame: number;
+ readonly playing: boolean;
+ play(): void;
+ pause(): void;
+ dispose(): void;
+};
+
+function makeFaviconAnimation(
+ frames: readonly string[],
+ options?: FaviconAnimationOptions,
+): FaviconAnimationController;
+
+function createFaviconAnimation(
+ frames: MaybeAccessor,
+ options?: FaviconAnimationOptions,
+): {
+ frame: Accessor;
+ playing: Accessor;
+ play(): void;
+ pause(): void;
+};
+```
+
+### `makeFaviconBadge` / `createFaviconBadge`
+
+Overlays a notification count, short string, or plain dot onto a base icon — the way native apps
+decorate their dock/taskbar icon.
+
+```ts
+const badge = makeFaviconBadge("/favicon.png", 3);
+badge.dispose(); // restores the previous favicon
+```
+
+```ts
+const href = createFaviconBadge("/favicon.png", () => unreadCount());
+```
+
+Value semantics:
+
+- `undefined | false | 0 | ""` → no badge, the base icon is shown as-is.
+- `true` → a plain dot, no text.
+- a `number` → digits, clamped to `"{max}+"` past `max` (default `99`).
+- a `string` → shown verbatim (keep it short — a badge is a small fraction of the icon).
+
+```ts
+type FaviconBadgeValue = number | string | boolean | undefined;
+
+type FaviconBadgePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+
+type FaviconBadgeOptions = {
+ /** @default "#e11d48" */
+ color?: string;
+ /** @default "#ffffff" */
+ textColor?: string;
+ /** @default "bottom-right" */
+ position?: FaviconBadgePosition;
+ /** Numbers above this render as "{max}+". @default 99 */
+ max?: number;
+ /** Badge diameter as a fraction of the icon's rendered size. @default 0.6 */
+ scale?: number;
+} & FaviconOptions;
+
+function makeFaviconBadge(
+ href: string,
+ value: FaviconBadgeValue,
+ options?: FaviconBadgeOptions,
+): FaviconController;
+
+function createFaviconBadge(
+ href: MaybeAccessor,
+ value: MaybeAccessor,
+ options?: FaviconBadgeOptions,
+): Accessor;
+```
+
+A failed base-image load falls back to the plain `href` — a broken badge overlay doesn't take down
+the favicon entirely.
+
+### `makeFaviconScheme` / `createFaviconScheme`
+
+Swaps between two icons to match the OS/browser `prefers-color-scheme`, staying in sync as that
+preference changes (e.g. the user's system switches modes, or they toggle it in devtools).
+
+```ts
+const favicon = makeFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" });
+favicon.scheme; // "light" | "dark"
+favicon.dispose(); // restores the previous favicon, stops watching the media query
+```
+
+```ts
+const favicon = createFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" });
+favicon.href(); // reactive, currently-applied href
+favicon.scheme(); // reactive, "light" | "dark"
+```
+
+```ts
+type FaviconColorScheme = "light" | "dark";
+
+type FaviconSchemeIcons = {
+ light: string;
+ dark: string;
+};
+
+type FaviconSchemeController = FaviconController & {
+ readonly scheme: FaviconColorScheme;
+};
+
+function makeFaviconScheme(
+ icons: FaviconSchemeIcons,
+ options?: FaviconOptions,
+): FaviconSchemeController;
+
+function createFaviconScheme(
+ icons: MaybeAccessor,
+ options?: FaviconOptions,
+): { href: Accessor; scheme: Accessor };
+```
+
+SSR has no `prefers-color-scheme` to read — both exports treat the server as `"light"`.
+
+### `makeFaviconProgress` / `createFaviconProgress`
+
+Overlays a progress ring (0–100) onto a base icon — for upload/download-style indicators.
+`undefined` shows the base icon with no ring at all; `0` **does** draw a ring (an empty track) —
+the opposite of the badge overlay above, where `0` means "no badge." Use `undefined` while there's
+no operation in progress, and `0`/a number once one starts.
+
+```ts
+const favicon = makeFaviconProgress("/favicon.png", 40);
+favicon.dispose(); // restores the previous favicon
+```
+
+```ts
+const href = createFaviconProgress("/favicon.png", () => uploadProgress());
+```
+
+```ts
+type FaviconProgressOptions = {
+ /** Color of the unfilled ring track. @default "rgba(0, 0, 0, 0.15)" */
+ trackColor?: string;
+ /** Color of the filled progress arc. @default "#6366f1" */
+ color?: string;
+ /** Ring stroke width as a fraction of the icon's rendered size. @default 0.15 */
+ thickness?: number;
+} & FaviconOptions;
+
+function makeFaviconProgress(
+ href: string,
+ progress: number | undefined,
+ options?: FaviconProgressOptions,
+): FaviconController;
+
+function createFaviconProgress(
+ href: MaybeAccessor,
+ progress: MaybeAccessor,
+ options?: FaviconProgressOptions,
+): Accessor;
+```
+
+Values are clamped to `[0, 100]`. Like the badge overlay, a failed base-image load falls back to
+the plain `href`.
+
+### `FaviconLink` — server-rendering the initial favicon
+
+Every `make*`/`create*` primitive above is SSR-safe in the sense that it won't crash on the
+server — but since it works by mutating `document.head` imperatively, **nothing about the favicon
+is rendered into the server-rendered HTML itself.** The actual `` shown before
+your client JS hydrates is whatever's already in your static document shell. `FaviconLink` closes
+that gap: it's a plain component that renders the `` tag directly, so its `href` is part of
+the initial HTML response.
+
+```tsx
+function Head() {
+ const scheme = createFaviconScheme({ light: "/icon-light.svg", dark: "/icon-dark.svg" });
+ return ;
+}
+```
+
+```ts
+type FaviconLinkProps = {
+ href: string;
+} & FaviconOptions;
+
+function FaviconLink(props: FaviconLinkProps): JSX.Element;
+```
+
+Place it once in your document's actual `` region — your SSR framework's root
+document/`` component, not your regular app body tree, since a `` rendered there
+wouldn't physically live in `` at all. A `make*`/`create*` call anywhere else in the app
+composes with it automatically: `bindFaviconLink`'s existing "reuse an existing link" lookup finds
+and takes over this exact element once it runs client-side — no coordination needed beyond both
+touching the same DOM node.
+
+For `createFaviconScheme`, this means the SSR-rendered HTML shows a _guessed_ initial icon (SSR
+has no `prefers-color-scheme` to read, so it's always `icons.light`) that the client corrects
+immediately on hydration if the OS actually prefers dark. For `createFaviconBadge`/
+`createFaviconProgress`, `FaviconLink` can only render the _base_ icon during SSR (no canvas
+server-side) — the badge/ring overlay still only appears once client JS composites it.
+
+#### SolidStart
+
+`FaviconLink` is a plain Solid component, so it drops straight into the `document` shell you
+already pass to `` in `src/entry-server.tsx` — no special wiring:
+
+```tsx
+// src/entry-server.tsx
+import { StartServer, createHandler } from "@solidjs/start/server";
+import { FaviconLink } from "@solid-primitives/favicon";
+
+export default createHandler(() => (
+ (
+
+
+
+ {assets}
+
+
+
{children}
+ {scripts}
+
+
+ )}
+ />
+));
+```
+
+A static `href` here is no better than a plain `` in `index.html` — `FaviconLink` earns its
+keep once the href is _computed server-side per request_. The `createFaviconScheme` guess-then-
+correct flash exists only because the client has no way to know the server's guess was wrong until
+after hydration; if you already have the real preference server-side (a `theme` cookie, for
+example), read it once and skip the guess entirely:
+
+```tsx
+// src/entry-server.tsx — same createHandler/StartServer wrapper as above, `document` swapped for:
+import { getRequestEvent } from "solid-js/web";
+import { FaviconLink } from "@solid-primitives/favicon";
+
+const document = ({ assets, children, scripts }) => {
+ const theme = getRequestEvent()?.request.headers.get("cookie")?.includes("theme=dark")
+ ? "dark"
+ : "light";
+ return (
+
+
+
+ {assets}
+
+
+
{children}
+ {scripts}
+
+
+ );
+};
+```
+
+Pair this with `createFaviconScheme` (or plain `createFavicon`) somewhere in your client tree to
+keep tracking `prefers-color-scheme` changes after load — its "reuse an existing link" lookup
+takes over the exact element `FaviconLink` rendered, so nothing further needs to change.
+
+#### Astro
+
+Astro renders imported framework components to static HTML by default — no `client:*` directive
+means zero JS ships for that component, which is exactly what a non-interactive `` tag
+wants. Import `FaviconLink` straight into your root layout's frontmatter and place it in ``:
+
+```astro
+---
+// src/layouts/Layout.astro
+import { FaviconLink } from "@solid-primitives/favicon";
+const theme = Astro.cookies.get("theme")?.value === "dark" ? "dark" : "light";
+---
+
+
+
+
+
+
+
+
+
+```
+
+To keep reacting to OS scheme changes after the page loads, put `createFaviconScheme` in its own
+component and hydrate just that island — `client:load` (or `client:idle`) is enough, since it only
+needs to run once to attach its `matchMedia` listener:
+
+```tsx
+// src/components/FaviconSync.tsx
+import { createFaviconScheme } from "@solid-primitives/favicon";
+
+export default function FaviconSync() {
+ createFaviconScheme({ light: "/icon-light.svg", dark: "/icon-dark.svg" });
+ return null;
+}
+```
+
+```astro
+
+```
+
+`bindFaviconLink`'s reuse lookup finds the `` the layout already rendered and takes it over,
+so the static SSR icon and the client-reactive one stay in sync without any explicit coordination
+between the two components.
+
+## Design notes
+
+See [DESIGN.md](./DESIGN.md) for the reasoning behind splitting this into small, focused primitive
+pairs (rather than one `createAdvancedFavicon`), the async-reactivity considerations, and the
+canvas/`Image`/`matchMedia` mocking strategy used in tests.
+
+## Demo
+
+See the [Storybook stories](./stories) for interactive examples of all five primitives.
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md)
diff --git a/packages/favicon/package.json b/packages/favicon/package.json
new file mode 100644
index 000000000..8d6b3f28b
--- /dev/null
+++ b/packages/favicon/package.json
@@ -0,0 +1,79 @@
+{
+ "name": "@solid-primitives/favicon",
+ "version": "0.0.100",
+ "description": "Reactive primitives for controlling the document favicon, including animated frame cycling and notification badges.",
+ "author": "David Di Biase ",
+ "contributors": [],
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/favicon",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "favicon",
+ "stage": 0,
+ "list": [
+ "makeFavicon",
+ "createFavicon",
+ "makeFaviconAnimation",
+ "createFaviconAnimation",
+ "makeFaviconBadge",
+ "createFaviconBadge",
+ "makeFaviconScheme",
+ "createFaviconScheme",
+ "makeFaviconProgress",
+ "createFaviconProgress",
+ "FaviconLink"
+ ],
+ "category": "Browser APIs"
+ },
+ "keywords": [
+ "solid",
+ "primitives",
+ "favicon",
+ "icon"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "typesVersions": {},
+ "tsdown": {
+ "entry": "src/**/*.{ts,tsx}",
+ "outDir": "dist"
+ },
+ "scripts": {
+ "build": "pnpm -w build",
+ "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "test": "pnpm run vitest",
+ "test:ssr": "pnpm run vitest --mode ssr"
+ },
+ "dependencies": {
+ "@solid-primitives/event-listener": "workspace:^",
+ "@solid-primitives/utils": "workspace:^"
+ },
+ "devDependencies": {
+ "@solidjs/web": "catalog:",
+ "solid-js": "catalog:"
+ },
+ "peerDependencies": {
+ "@solidjs/web": "catalog:peer",
+ "solid-js": "catalog:peer"
+ }
+}
diff --git a/packages/favicon/src/animation.ts b/packages/favicon/src/animation.ts
new file mode 100644
index 000000000..961018d7f
--- /dev/null
+++ b/packages/favicon/src/animation.ts
@@ -0,0 +1,213 @@
+import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils";
+import { makeEventListener } from "@solid-primitives/event-listener";
+import { makeFavicon } from "./favicon.ts";
+import type { FaviconOptions } from "./link.ts";
+
+export type FaviconAnimationOptions = {
+ /** Milliseconds between frames. @default 200 */
+ interval?: number;
+ /** Start cycling immediately. @default true */
+ autoplay?: boolean;
+ /** Loop back to the first frame after the last. @default true */
+ loop?: boolean;
+} & FaviconOptions;
+
+export type FaviconAnimationController = {
+ /** The href of the currently-displayed frame. */
+ readonly href: string;
+ /** Index of the currently-displayed frame. */
+ readonly frame: number;
+ readonly playing: boolean;
+ play: () => void;
+ pause: () => void;
+ dispose: () => void;
+};
+
+/** Returns the next frame index, or `undefined` when a non-looping sequence has ended. */
+function nextFrameIndex(frame: number, length: number, loop: boolean): number | undefined {
+ const next = frame + 1;
+ if (next < length) return next;
+ return loop ? 0 : undefined;
+}
+
+/**
+ * Cycles the document favicon through `frames` on an interval — for build-status spinners,
+ * "recording" pulses, and similar loading indicators.
+ *
+ * Non-reactive: `frames` is a plain array, read once. For a reactive, auto-pausing-on-hidden-tab
+ * version see {@link createFaviconAnimation}.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconAnimation
+ * @example
+ * const anim = makeFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"]);
+ * anim.pause();
+ * anim.dispose(); // restores the previous favicon
+ */
+export function makeFaviconAnimation(
+ frames: readonly string[],
+ options: FaviconAnimationOptions = {},
+): FaviconAnimationController {
+ const { interval = 200, autoplay = true, loop = true, ...faviconOptions } = options;
+
+ if (isServer || frames.length === 0) {
+ return {
+ href: frames[0] ?? "",
+ frame: 0,
+ playing: false,
+ play: noop,
+ pause: noop,
+ dispose: noop,
+ };
+ }
+
+ const favicon = makeFavicon(frames[0]!, faviconOptions);
+ let frame = 0;
+ let playing = false;
+ let timer: ReturnType | undefined;
+
+ function tick(): void {
+ const next = nextFrameIndex(frame, frames.length, loop);
+ if (next === undefined) {
+ pause();
+ return;
+ }
+ frame = next;
+ favicon.setHref(frames[frame]!);
+ }
+
+ function play(): void {
+ if (playing || frames.length <= 1) return;
+ playing = true;
+ timer = setInterval(tick, interval);
+ }
+
+ function pause(): void {
+ playing = false;
+ if (timer !== undefined) {
+ clearInterval(timer);
+ timer = undefined;
+ }
+ }
+
+ if (autoplay) play();
+
+ return {
+ get href() {
+ return favicon.href;
+ },
+ get frame() {
+ return frame;
+ },
+ get playing() {
+ return playing;
+ },
+ play,
+ pause,
+ dispose() {
+ pause();
+ favicon.dispose();
+ },
+ };
+}
+
+/**
+ * Reactively cycles the document favicon through `frames` on an interval. Automatically pauses
+ * while the tab is hidden (`document.visibilitychange`) and resumes if it was playing before, so
+ * a backgrounded tab doesn't keep repainting an icon nobody can see. Restores the previous
+ * favicon on cleanup.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconAnimation
+ * @example
+ * const spinner = createFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"]);
+ * spinner.pause();
+ */
+export function createFaviconAnimation(
+ frames: MaybeAccessor,
+ options: FaviconAnimationOptions = {},
+): {
+ frame: Accessor;
+ playing: Accessor;
+ play: () => void;
+ pause: () => void;
+} {
+ if (isServer) {
+ return { frame: () => 0, playing: () => false, play: noop, pause: noop };
+ }
+
+ const { interval = 200, autoplay = true, loop = true, ...faviconOptions } = options;
+
+ const [frame, setFrame] = createSignal(0, INTERNAL_OPTIONS);
+ const [playing, setPlaying] = createSignal(false, INTERNAL_OPTIONS);
+
+ // `untrack` — read outside JSX/a memo/an effect's compute phase would otherwise trip Solid's
+ // `STRICT_READ_UNTRACKED` dev diagnostic.
+ let list = untrack(() => access(frames));
+ const favicon = makeFavicon(list[0] ?? "", faviconOptions);
+ let timer: ReturnType | undefined;
+
+ // Every read below is a plain imperative "what's the current value" check from outside a
+ // tracked context (a timer callback, an event handler, or setup code) — `untrack` keeps it
+ // from accidentally registering a dependency on whatever computation happens to be active
+ // when these functions are called (and avoids Solid's `STRICT_READ_UNTRACKED` dev diagnostic).
+ function tick(): void {
+ const next = nextFrameIndex(untrack(frame), list.length, loop);
+ if (next === undefined) {
+ pause();
+ return;
+ }
+ setFrame(next);
+ favicon.setHref(list[next]!);
+ }
+
+ function play(): void {
+ if (untrack(playing) || list.length <= 1) return;
+ setPlaying(true);
+ timer = setInterval(tick, interval);
+ }
+
+ function pause(): void {
+ setPlaying(false);
+ if (timer !== undefined) {
+ clearInterval(timer);
+ timer = undefined;
+ }
+ }
+
+ if (typeof frames === "function") {
+ // `defer: true` — the initial list was already applied synchronously above; without this,
+ // the effect's un-deferred first run would re-apply frame 0 on top of whatever `autoplay`
+ // had already ticked forward.
+ createEffect(
+ () => frames(),
+ newList => {
+ list = newList;
+ setFrame(0);
+ favicon.setHref(list[0] ?? "");
+ if (untrack(playing) && list.length <= 1) pause();
+ },
+ { defer: true },
+ );
+ }
+
+ let wasPlayingBeforeHidden = false;
+ makeEventListener(document, "visibilitychange", () => {
+ if (document.hidden) {
+ wasPlayingBeforeHidden = untrack(playing);
+ if (wasPlayingBeforeHidden) pause();
+ } else if (wasPlayingBeforeHidden) {
+ wasPlayingBeforeHidden = false;
+ play();
+ }
+ });
+
+ if (autoplay) play();
+
+ onCleanup(() => {
+ pause();
+ favicon.dispose();
+ });
+
+ return { frame, playing, play, pause };
+}
diff --git a/packages/favicon/src/badge.ts b/packages/favicon/src/badge.ts
new file mode 100644
index 000000000..ff2257a4d
--- /dev/null
+++ b/packages/favicon/src/badge.ts
@@ -0,0 +1,127 @@
+import type { Accessor } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { type MaybeAccessor, noop } from "@solid-primitives/utils";
+import { makeFavicon } from "./favicon.ts";
+import { createCanvasFavicon, drawBaseIcon } from "./canvas.ts";
+import type { FaviconController, FaviconOptions } from "./link.ts";
+
+export type FaviconBadgeValue = number | string | boolean | undefined;
+
+export type FaviconBadgePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+
+export type FaviconBadgeOptions = {
+ /** Background color of the badge. @default "#e11d48" */
+ color?: string;
+ /** Text color drawn inside the badge. @default "#ffffff" */
+ textColor?: string;
+ /** Corner the badge is drawn in. @default "bottom-right" */
+ position?: FaviconBadgePosition;
+ /** Numeric values greater than `max` render as `"{max}+"`. @default 99 */
+ max?: number;
+ /** Badge diameter as a fraction of the icon's rendered size. @default 0.6 */
+ scale?: number;
+} & FaviconOptions;
+
+/** `0`, `false`, `undefined`, and `""` all mean "no badge" — the base icon is used as-is. */
+function shouldShowBadge(value: FaviconBadgeValue): boolean {
+ return (
+ value === true ||
+ (typeof value === "number" && value !== 0) ||
+ (typeof value === "string" && value !== "")
+ );
+}
+
+function badgeText(value: FaviconBadgeValue, max: number): string | undefined {
+ if (typeof value === "number") return value > max ? `${max}+` : `${value}`;
+ if (typeof value === "string") return value;
+ return undefined; // `true` — dot only, no text
+}
+
+/**
+ * Composites `value` as a badge onto `href`, returning a data URL. Returns `href` unchanged when
+ * `value` doesn't warrant a badge, or when the base image fails to load — a broken badge overlay
+ * shouldn't take down the favicon entirely.
+ */
+async function renderBadgeIcon(
+ href: string,
+ value: FaviconBadgeValue,
+ options: FaviconBadgeOptions,
+): Promise {
+ if (!shouldShowBadge(value)) return href;
+
+ const {
+ color = "#e11d48",
+ textColor = "#ffffff",
+ position = "bottom-right",
+ max = 99,
+ scale = 0.6,
+ } = options;
+
+ const base = await drawBaseIcon(href);
+ if (!base) return href;
+ const { canvas, ctx, size } = base;
+
+ const radius = (size * scale) / 2;
+ const cx = position.endsWith("right") ? size - radius : radius;
+ const cy = position.startsWith("bottom") ? size - radius : radius;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+
+ const text = badgeText(value, max);
+ if (text !== undefined) {
+ ctx.fillStyle = textColor;
+ ctx.font = `${Math.round(radius * (text.length > 1 ? 1.1 : 1.4))}px sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(text, cx, cy);
+ }
+
+ return canvas.toDataURL("image/png");
+}
+
+/**
+ * Overlays a notification badge (a count, short string, or plain dot) on top of `href` and sets
+ * it as the document favicon.
+ *
+ * Non-reactive: `href`/`value` are read once. For a reactive, auto-redrawing version see
+ * {@link createFaviconBadge}.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconBadge
+ * @example
+ * const badge = makeFaviconBadge("/favicon.png", 3);
+ * badge.dispose(); // restores the previous favicon
+ */
+export function makeFaviconBadge(
+ href: string,
+ value: FaviconBadgeValue,
+ options: FaviconBadgeOptions = {},
+): FaviconController {
+ if (isServer) {
+ return { href, setHref: noop, dispose: noop };
+ }
+
+ const favicon = makeFavicon(href, options);
+ void renderBadgeIcon(href, value, options).then(dataUrl => favicon.setHref(dataUrl));
+ return favicon;
+}
+
+/**
+ * Reactively overlays a notification badge on top of `href`, redrawing whenever `href` or `value`
+ * change. Restores the previous favicon on cleanup.
+ *
+ * @returns an accessor of the currently-applied (possibly badged) favicon href
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconBadge
+ * @example
+ * const href = createFaviconBadge("/favicon.png", () => unreadCount());
+ */
+export function createFaviconBadge(
+ href: MaybeAccessor,
+ value: MaybeAccessor,
+ options: FaviconBadgeOptions = {},
+): Accessor {
+ return createCanvasFavicon(href, value, options, renderBadgeIcon);
+}
diff --git a/packages/favicon/src/canvas.ts b/packages/favicon/src/canvas.ts
new file mode 100644
index 000000000..b1f2fe698
--- /dev/null
+++ b/packages/favicon/src/canvas.ts
@@ -0,0 +1,97 @@
+import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, type MaybeAccessor } from "@solid-primitives/utils";
+import { makeFavicon } from "./favicon.ts";
+import type { FaviconOptions } from "./link.ts";
+
+/** Loads `src` into an `Image`, resolving once it's decoded (or rejecting on error). */
+export function loadImage(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error(`favicon: failed to load image "${src}"`));
+ img.src = src;
+ });
+}
+
+/**
+ * Loads `href` and draws it onto a fresh, appropriately-sized offscreen canvas. Returns
+ * `undefined` if the image fails to load or the canvas context is unavailable — callers should
+ * fall back to the plain `href` rather than let a broken overlay take down the favicon entirely.
+ */
+export async function drawBaseIcon(
+ href: string,
+): Promise<{ canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; size: number } | undefined> {
+ let img: HTMLImageElement;
+ try {
+ img = await loadImage(href);
+ } catch {
+ return undefined;
+ }
+ const size = img.naturalWidth || img.naturalHeight || 32;
+
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return undefined;
+
+ ctx.clearRect(0, 0, size, size);
+ ctx.drawImage(img, 0, 0, size, size);
+
+ return { canvas, ctx, size };
+}
+
+/**
+ * Shared reactive wiring for `create*` primitives that composite a canvas overlay onto a base
+ * icon (badge, progress ring, …): applies `render(href, value, options)`'s result via
+ * `makeFavicon`, coalescing out-of-order async completions so only the most recent `href`/`value`
+ * pair's draw ever gets applied, and restores the previous favicon `onCleanup`. Extracted once a
+ * second such primitive (`progress.ts`) needed the exact same wiring `badge.ts` already had —
+ * this is behavior with a real correctness property (the coalescing), not just boilerplate, so
+ * keeping it in one place means a future fix to it can't accidentally apply to only one caller.
+ */
+export function createCanvasFavicon(
+ href: MaybeAccessor,
+ value: MaybeAccessor,
+ options: Options,
+ render: (href: string, value: Value, options: Options) => Promise,
+): Accessor {
+ if (isServer) {
+ return () => access(href);
+ }
+
+ // `untrack` — read outside JSX/a memo/an effect's compute phase would otherwise trip Solid's
+ // `STRICT_READ_UNTRACKED` dev diagnostic.
+ const favicon = makeFavicon(
+ untrack(() => access(href)),
+ options,
+ );
+ const [current, setCurrent] = createSignal(favicon.href, INTERNAL_OPTIONS);
+
+ let requestId = 0;
+ const redraw = (h: string, v: Value): void => {
+ const id = ++requestId;
+ void render(h, v, options).then(dataUrl => {
+ if (id !== requestId) return; // superseded by a more recent href/value change
+ favicon.setHref(dataUrl);
+ // Read back the DOM-resolved href (browsers resolve relative `href`s to absolute URLs)
+ // rather than trusting `dataUrl` verbatim, so this always matches the non-reactive `make*`
+ // variant's `.href` getter.
+ setCurrent(favicon.href);
+ });
+ };
+
+ if (typeof href === "function" || typeof value === "function") {
+ createEffect(
+ () => [access(href), access(value)] as const,
+ ([h, v]) => redraw(h, v),
+ );
+ } else {
+ redraw(href, value);
+ }
+
+ onCleanup(favicon.dispose);
+
+ return current;
+}
diff --git a/packages/favicon/src/components.tsx b/packages/favicon/src/components.tsx
new file mode 100644
index 000000000..76614a55d
--- /dev/null
+++ b/packages/favicon/src/components.tsx
@@ -0,0 +1,27 @@
+import type { Component } from "solid-js";
+import type { FaviconOptions } from "./link.ts";
+
+export type FaviconLinkProps = {
+ /** The favicon href to render. Pass a value, not an accessor — call it at the JSX boundary. */
+ href: string;
+} & FaviconOptions;
+
+/**
+ * Renders the favicon `` element directly, so its initial `href` is part of the server-
+ * rendered HTML instead of only being applied once client JS hydrates. Place it once in your
+ * document's actual `` region (e.g. your SSR framework's root document/`` component —
+ * not your regular app body tree, since a `` rendered there wouldn't live in `` at
+ * all) alongside a `make*`/`create*` call anywhere else in the app: `bindFaviconLink`'s existing
+ * "reuse an existing link" lookup finds and takes over this exact element once it runs
+ * client-side, so nothing else needs to change to compose the two.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#FaviconLink
+ * @example
+ * function Head() {
+ * const scheme = createFaviconScheme({ light: "/icon-light.svg", dark: "/icon-dark.svg" });
+ * return ;
+ * }
+ */
+export const FaviconLink: Component = props => (
+
+);
diff --git a/packages/favicon/src/favicon.ts b/packages/favicon/src/favicon.ts
new file mode 100644
index 000000000..1a84d38df
--- /dev/null
+++ b/packages/favicon/src/favicon.ts
@@ -0,0 +1,70 @@
+import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils";
+import { bindFaviconLink, type FaviconController, type FaviconOptions } from "./link.ts";
+
+/**
+ * Sets the document's favicon to `href`, restoring whatever href (or absence of a link element)
+ * was there before once `dispose()` is called.
+ *
+ * Non-reactive: `href` is a plain string. For a reactive, auto-disposing version see
+ * {@link createFavicon}.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFavicon
+ * @example
+ * const favicon = makeFavicon("/favicon-dark.svg");
+ * favicon.setHref("/favicon-light.svg");
+ * favicon.dispose(); // restores the previous favicon
+ */
+export function makeFavicon(href: string, options: FaviconOptions = {}): FaviconController {
+ if (isServer) {
+ return { href, setHref: noop, dispose: noop };
+ }
+ return bindFaviconLink(options.rel ?? "icon", href);
+}
+
+/**
+ * Reactively sets the document's favicon to `href`. Restores the previous favicon on cleanup.
+ *
+ * @param href a url/path, or an accessor returning one, to apply as the favicon
+ * @returns an accessor of the currently-applied favicon href
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFavicon
+ * @example
+ * const href = createFavicon(() => (unreadCount() > 0 ? "/favicon-alert.svg" : "/favicon.svg"));
+ */
+export function createFavicon(
+ href: MaybeAccessor,
+ options: FaviconOptions = {},
+): Accessor {
+ if (isServer) {
+ return () => access(href);
+ }
+
+ // `untrack` — this initial read must not happen as a top-level tracked read (it isn't inside
+ // JSX, a memo, or an effect's compute phase), or Solid's dev-mode diagnostics flag it as
+ // `STRICT_READ_UNTRACKED`.
+ const favicon = makeFavicon(
+ untrack(() => access(href)),
+ options,
+ );
+ // A real signal, not just `() => favicon.href` — reading a plain DOM property from JSX
+ // registers no dependency, so it would never re-render when the href changes underneath it.
+ const [current, setCurrent] = createSignal(favicon.href, INTERNAL_OPTIONS);
+
+ if (typeof href === "function") {
+ // `defer: true` — the initial value was already applied synchronously above via `makeFavicon`.
+ createEffect(
+ () => href(),
+ value => {
+ favicon.setHref(value);
+ setCurrent(favicon.href);
+ },
+ { defer: true },
+ );
+ }
+
+ onCleanup(favicon.dispose);
+
+ return current;
+}
diff --git a/packages/favicon/src/index.ts b/packages/favicon/src/index.ts
new file mode 100644
index 000000000..0542d25b4
--- /dev/null
+++ b/packages/favicon/src/index.ts
@@ -0,0 +1,28 @@
+export type { FaviconController, FaviconOptions, FaviconRel } from "./link.ts";
+export { makeFavicon, createFavicon } from "./favicon.ts";
+export {
+ makeFaviconAnimation,
+ createFaviconAnimation,
+ type FaviconAnimationOptions,
+ type FaviconAnimationController,
+} from "./animation.ts";
+export {
+ makeFaviconBadge,
+ createFaviconBadge,
+ type FaviconBadgeValue,
+ type FaviconBadgePosition,
+ type FaviconBadgeOptions,
+} from "./badge.ts";
+export {
+ makeFaviconScheme,
+ createFaviconScheme,
+ type FaviconColorScheme,
+ type FaviconSchemeIcons,
+ type FaviconSchemeController,
+} from "./scheme.ts";
+export {
+ makeFaviconProgress,
+ createFaviconProgress,
+ type FaviconProgressOptions,
+} from "./progress.ts";
+export { FaviconLink, type FaviconLinkProps } from "./components.tsx";
diff --git a/packages/favicon/src/link.ts b/packages/favicon/src/link.ts
new file mode 100644
index 000000000..c1dce436f
--- /dev/null
+++ b/packages/favicon/src/link.ts
@@ -0,0 +1,52 @@
+export type FaviconRel = "icon" | "shortcut icon" | "apple-touch-icon";
+
+export type FaviconOptions = {
+ /** `rel` attribute of the favicon `` element. */
+ rel?: FaviconRel;
+};
+
+export type FaviconController = {
+ /** The href currently applied to the favicon link. */
+ readonly href: string;
+ /** Update the favicon href. */
+ setHref: (href: string) => void;
+ /** Restores the href captured before this controller ran, or removes the link it created. */
+ dispose: () => void;
+};
+
+/**
+ * Finds the document's favicon `` for the given `rel`, creating and appending one if none
+ * exists. Captures whatever href was present before this call so callers can restore it later —
+ * each call snapshots independently, so sequential/nested callers compose like a stack as long as
+ * disposal happens in reverse of creation order (the normal case for component mount/unmount).
+ */
+export function bindFaviconLink(rel: FaviconRel, href: string): FaviconController {
+ let link = document.head.querySelector(`link[rel="${rel}"]`);
+ const created = link == null;
+ const previousHref = link?.href;
+
+ if (!link) {
+ link = document.createElement("link");
+ link.rel = rel;
+ document.head.appendChild(link);
+ }
+
+ link.href = href;
+ const el = link;
+
+ return {
+ get href() {
+ return el.href;
+ },
+ setHref(next: string) {
+ el.href = next;
+ },
+ dispose() {
+ if (created) {
+ el.remove();
+ } else if (previousHref !== undefined) {
+ el.href = previousHref;
+ }
+ },
+ };
+}
diff --git a/packages/favicon/src/progress.ts b/packages/favicon/src/progress.ts
new file mode 100644
index 000000000..017481dec
--- /dev/null
+++ b/packages/favicon/src/progress.ts
@@ -0,0 +1,103 @@
+import type { Accessor } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { type MaybeAccessor, noop } from "@solid-primitives/utils";
+import { makeFavicon } from "./favicon.ts";
+import { createCanvasFavicon, drawBaseIcon } from "./canvas.ts";
+import type { FaviconController, FaviconOptions } from "./link.ts";
+
+export type FaviconProgressOptions = {
+ /** Color of the unfilled ring track. @default "rgba(0, 0, 0, 0.15)" */
+ trackColor?: string;
+ /** Color of the filled progress arc. @default "#6366f1" */
+ color?: string;
+ /** Ring stroke width as a fraction of the icon's rendered size. @default 0.15 */
+ thickness?: number;
+} & FaviconOptions;
+
+/**
+ * Composites `progress` as a ring around `href`, returning a data URL. Returns `href` unchanged
+ * when `progress` is `undefined`, or when the base image fails to load — a broken ring overlay
+ * shouldn't take down the favicon entirely.
+ */
+async function renderProgressIcon(
+ href: string,
+ progress: number | undefined,
+ options: FaviconProgressOptions,
+): Promise {
+ if (progress === undefined) return href;
+
+ const { trackColor = "rgba(0, 0, 0, 0.15)", color = "#6366f1", thickness = 0.15 } = options;
+ const clamped = Math.min(100, Math.max(0, progress));
+
+ const base = await drawBaseIcon(href);
+ if (!base) return href;
+ const { canvas, ctx, size } = base;
+
+ const lineWidth = size * thickness;
+ const radius = (size - lineWidth) / 2;
+ const cx = size / 2;
+ const cy = size / 2;
+
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = "round";
+
+ ctx.strokeStyle = trackColor;
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.stroke();
+
+ if (clamped > 0) {
+ ctx.strokeStyle = color;
+ ctx.beginPath();
+ const start = -Math.PI / 2;
+ const end = start + (Math.PI * 2 * clamped) / 100;
+ ctx.arc(cx, cy, radius, start, end);
+ ctx.stroke();
+ }
+
+ return canvas.toDataURL("image/png");
+}
+
+/**
+ * Overlays a progress ring (0–100) around `href` and sets it as the document favicon — for
+ * upload/download-style indicators. `undefined` shows the base icon with no ring.
+ *
+ * Non-reactive: `href`/`progress` are read once. For a reactive, auto-redrawing version see
+ * {@link createFaviconProgress}.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconProgress
+ * @example
+ * const favicon = makeFaviconProgress("/favicon.png", 40);
+ * favicon.dispose(); // restores the previous favicon
+ */
+export function makeFaviconProgress(
+ href: string,
+ progress: number | undefined,
+ options: FaviconProgressOptions = {},
+): FaviconController {
+ if (isServer) {
+ return { href, setHref: noop, dispose: noop };
+ }
+
+ const favicon = makeFavicon(href, options);
+ void renderProgressIcon(href, progress, options).then(dataUrl => favicon.setHref(dataUrl));
+ return favicon;
+}
+
+/**
+ * Reactively overlays a progress ring around `href`, redrawing whenever `href` or `progress`
+ * change. Restores the previous favicon on cleanup.
+ *
+ * @returns an accessor of the currently-applied (possibly ringed) favicon href
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconProgress
+ * @example
+ * const href = createFaviconProgress("/favicon.png", () => uploadProgress());
+ */
+export function createFaviconProgress(
+ href: MaybeAccessor,
+ progress: MaybeAccessor,
+ options: FaviconProgressOptions = {},
+): Accessor {
+ return createCanvasFavicon(href, progress, options, renderProgressIcon);
+}
diff --git a/packages/favicon/src/scheme.ts b/packages/favicon/src/scheme.ts
new file mode 100644
index 000000000..1613e4e18
--- /dev/null
+++ b/packages/favicon/src/scheme.ts
@@ -0,0 +1,120 @@
+import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils";
+import { makeEventListener } from "@solid-primitives/event-listener";
+import { makeFavicon } from "./favicon.ts";
+import type { FaviconController, FaviconOptions } from "./link.ts";
+
+export type FaviconColorScheme = "light" | "dark";
+
+export type FaviconSchemeIcons = {
+ light: string;
+ dark: string;
+};
+
+export type FaviconSchemeController = FaviconController & {
+ /** Which icon is currently showing, based on `prefers-color-scheme`. */
+ readonly scheme: FaviconColorScheme;
+};
+
+/** SSR has no `matchMedia` to query — every export here treats the server as `"light"`. */
+const SERVER_SCHEME: FaviconColorScheme = "light";
+
+/**
+ * Sets the document favicon to `icons.light` or `icons.dark` to match the OS/browser
+ * `prefers-color-scheme`, and keeps it in sync as that preference changes.
+ *
+ * Non-reactive: `icons` is a plain object, read once (the light/dark swap still keeps working —
+ * only the two href strings themselves are static). For a reactive, auto-disposing version see
+ * {@link createFaviconScheme}.
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconScheme
+ * @example
+ * const favicon = makeFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" });
+ * favicon.dispose(); // restores the previous favicon, stops watching the media query
+ */
+export function makeFaviconScheme(
+ icons: FaviconSchemeIcons,
+ options: FaviconOptions = {},
+): FaviconSchemeController {
+ if (isServer) {
+ return { href: icons[SERVER_SCHEME], scheme: SERVER_SCHEME, setHref: noop, dispose: noop };
+ }
+
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
+ let scheme: FaviconColorScheme = mql.matches ? "dark" : "light";
+ const favicon = makeFavicon(icons[scheme], options);
+
+ const clear = makeEventListener<{ change: MediaQueryListEvent }>(mql, "change", event => {
+ scheme = event.matches ? "dark" : "light";
+ favicon.setHref(icons[scheme]);
+ });
+
+ return {
+ get href() {
+ return favicon.href;
+ },
+ get scheme() {
+ return scheme;
+ },
+ setHref: favicon.setHref,
+ dispose() {
+ clear();
+ favicon.dispose();
+ },
+ };
+}
+
+/**
+ * Reactively sets the document favicon to `icons.light` or `icons.dark` to match
+ * `prefers-color-scheme`, updating on both an `icons` change and an OS-level scheme change.
+ * Restores the previous favicon on cleanup.
+ *
+ * @returns the currently-applied href and the currently-active scheme, both reactive
+ *
+ * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconScheme
+ * @example
+ * const favicon = createFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" });
+ * favicon.scheme(); // "light" | "dark"
+ */
+export function createFaviconScheme(
+ icons: MaybeAccessor,
+ options: FaviconOptions = {},
+): { href: Accessor; scheme: Accessor } {
+ if (isServer) {
+ // Lazy, like `createFavicon`'s `() => access(href)` — re-reads `icons` on every call rather
+ // than capturing a stale snapshot, in case it's a reactive accessor.
+ return { href: () => access(icons)[SERVER_SCHEME], scheme: () => SERVER_SCHEME };
+ }
+
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
+ const [scheme, setScheme] = createSignal(
+ mql.matches ? "dark" : "light",
+ INTERNAL_OPTIONS,
+ );
+
+ // `untrack` — this initial read must not happen as a top-level tracked read (it isn't inside
+ // JSX, a memo, or an effect's compute phase), or Solid's dev-mode diagnostics flag it as
+ // `STRICT_READ_UNTRACKED`.
+ const initialIcons = untrack(() => access(icons));
+ const favicon = makeFavicon(initialIcons[untrack(scheme)], options);
+ const [href, setHref] = createSignal(favicon.href, INTERNAL_OPTIONS);
+
+ makeEventListener<{ change: MediaQueryListEvent }>(mql, "change", event => {
+ setScheme(event.matches ? "dark" : "light");
+ });
+
+ // `defer: true` — the initial value was already applied synchronously above.
+ createEffect(
+ () => [access(icons), scheme()] as const,
+ ([currentIcons, currentScheme]) => {
+ favicon.setHref(currentIcons[currentScheme]);
+ setHref(favicon.href);
+ },
+ { defer: true },
+ );
+
+ onCleanup(favicon.dispose);
+
+ return { href, scheme };
+}
diff --git a/packages/favicon/stories/_helpers.tsx b/packages/favicon/stories/_helpers.tsx
new file mode 100644
index 000000000..a11ac31d7
--- /dev/null
+++ b/packages/favicon/stories/_helpers.tsx
@@ -0,0 +1,29 @@
+import { colors, font, radii } from "../../../.storybook/ui/index.js";
+
+/** A small inline SVG data URI — avoids needing binary assets registered in `staticDirs`. */
+export const svgIcon = (fill: string): string =>
+ `data:image/svg+xml,${encodeURIComponent(
+ ``,
+ )}`;
+
+/** Preview box mirroring what the real (invisible-in-canvas) browser tab favicon currently is. */
+export const IconPreview = (props: { href: string; label?: string }) => (
+
+
+
+ {props.label ?? "current favicon"}
+
+
+);
diff --git a/packages/favicon/stories/createFavicon.stories.tsx b/packages/favicon/stories/createFavicon.stories.tsx
new file mode 100644
index 000000000..8496abe6f
--- /dev/null
+++ b/packages/favicon/stories/createFavicon.stories.tsx
@@ -0,0 +1,61 @@
+import { createSignal } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import { createFavicon } from "@solid-primitives/favicon";
+import readme from "../README.md?raw";
+import { Button, ButtonRow, Container, Section, BoolRow } from "../../../.storybook/ui/index.js";
+import { svgIcon, IconPreview } from "./_helpers.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/Favicon",
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component: readme,
+ },
+ },
+ },
+});
+
+export default meta;
+
+const ICONS = {
+ default: svgIcon("#6366f1"),
+ alert: svgIcon("#e11d48"),
+};
+
+export const BasicSwap = meta.story({
+ name: "Reactive swap",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`createFavicon` reactively swaps the document favicon (visible in the real browser tab — try switching away and back). This story also mirrors the current href in a preview box since the actual tab icon isn't visible inside the canvas.",
+ },
+ },
+ },
+ render: () => {
+ const [alert, setAlert] = createSignal(false);
+ const href = createFavicon(() => (alert() ? ICONS.alert : ICONS.default));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/favicon/stories/createFaviconAnimation.stories.tsx b/packages/favicon/stories/createFaviconAnimation.stories.tsx
new file mode 100644
index 000000000..65e4245cd
--- /dev/null
+++ b/packages/favicon/stories/createFaviconAnimation.stories.tsx
@@ -0,0 +1,60 @@
+import preview from "../../../.storybook/preview.js";
+import { createFaviconAnimation } from "@solid-primitives/favicon";
+import {
+ Button,
+ ButtonRow,
+ Container,
+ Section,
+ BoolRow,
+ StatRow,
+} from "../../../.storybook/ui/index.js";
+import { svgIcon, IconPreview } from "./_helpers.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/Favicon",
+});
+
+export default meta;
+
+const FRAMES = ["#6366f1", "#8b5cf6", "#d946ef", "#f43f5e", "#f97316"].map(svgIcon);
+
+export const Spinner = meta.story({
+ name: "Loading spinner",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Cycles the favicon through a sequence of frames on an interval — for build-status spinners or "recording" pulses. Automatically pauses while the tab is hidden and resumes when it becomes visible again; switch away from this browser tab while playing to see it happen (`playing` below will flip back to `true` on return).',
+ },
+ },
+ },
+ render: () => {
+ const spinner = createFaviconAnimation(FRAMES, { interval: 150 });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/favicon/stories/createFaviconBadge.stories.tsx b/packages/favicon/stories/createFaviconBadge.stories.tsx
new file mode 100644
index 000000000..ddfb88586
--- /dev/null
+++ b/packages/favicon/stories/createFaviconBadge.stories.tsx
@@ -0,0 +1,51 @@
+import { createSignal } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import { createFaviconBadge } from "@solid-primitives/favicon";
+import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js";
+import { svgIcon, IconPreview } from "./_helpers.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/Favicon",
+});
+
+export default meta;
+
+const BASE_ICON = svgIcon("#6366f1");
+
+export const NotificationBadge = meta.story({
+ name: "Unread count badge",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Overlays a notification count on the base icon, the way native apps decorate their dock/taskbar icon. `0` renders no badge; numbers clamp to `"{max}+"` past `max` (default 99).',
+ },
+ },
+ },
+ render: () => {
+ const [count, setCount] = createSignal(3);
+ const href = createFaviconBadge(BASE_ICON, count, { max: 9 });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/favicon/stories/createFaviconProgress.stories.tsx b/packages/favicon/stories/createFaviconProgress.stories.tsx
new file mode 100644
index 000000000..251f67ff1
--- /dev/null
+++ b/packages/favicon/stories/createFaviconProgress.stories.tsx
@@ -0,0 +1,112 @@
+import { createEffect, createSignal, Show } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import { createFaviconProgress } from "@solid-primitives/favicon";
+import {
+ Button,
+ ButtonRow,
+ Container,
+ Section,
+ StatRow,
+ colors,
+ font,
+} from "../../../.storybook/ui/index.js";
+import { svgIcon, IconPreview } from "./_helpers.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/Favicon",
+});
+
+export default meta;
+
+const BASE_ICON = svgIcon("#6366f1");
+
+export const Progress = meta.story({
+ name: "Upload progress ring",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Overlays a progress ring on the base icon — for upload/download-style indicators. `undefined` shows the base icon with no ring at all; drag to 0 to see the (still visible) empty track.",
+ },
+ },
+ },
+ render: () => {
+ const [progress, setProgress] = createSignal(40);
+ const [simulating, setSimulating] = createSignal(false);
+ const href = createFaviconProgress(BASE_ICON, progress);
+
+ // The interval's lifetime is tied directly to the `simulating` signal via the effect's
+ // apply-phase cleanup — Solid guarantees it's cleared whenever `simulating` flips back to
+ // `false` (Clear, manual drag, reaching 100%, or unmount), with no manual timer bookkeeping.
+ createEffect(
+ () => simulating(),
+ isSimulating => {
+ if (!isSimulating) return;
+ const id = setInterval(() => {
+ setProgress(p => {
+ const next = (p ?? 0) + 5;
+ if (next >= 100) {
+ setSimulating(false);
+ return 100;
+ }
+ return next;
+ });
+ }, 120);
+ return () => clearInterval(id);
+ },
+ );
+
+ return (
+
+
+
+ {
+ setSimulating(false);
+ setProgress(Number(e.currentTarget.value));
+ }}
+ style={{ width: "100%" }}
+ />
+
+
+
+
+
+
+
+ no ring
+ }
+ >
+
+
+
+
+ );
+ },
+});
diff --git a/packages/favicon/stories/createFaviconScheme.stories.tsx b/packages/favicon/stories/createFaviconScheme.stories.tsx
new file mode 100644
index 000000000..a534740f9
--- /dev/null
+++ b/packages/favicon/stories/createFaviconScheme.stories.tsx
@@ -0,0 +1,56 @@
+import { createSignal } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import { createFaviconScheme } from "@solid-primitives/favicon";
+import {
+ Button,
+ ButtonRow,
+ Container,
+ Section,
+ BoolRow,
+ StatRow,
+} from "../../../.storybook/ui/index.js";
+import { svgIcon, IconPreview } from "./_helpers.js";
+
+const meta = preview.meta({
+ title: "Browser APIs/Favicon",
+});
+
+export default meta;
+
+const ICON_SETS = [
+ { light: svgIcon("#6366f1"), dark: svgIcon("#a5f3fc") },
+ { light: svgIcon("#16a34a"), dark: svgIcon("#f59e0b") },
+];
+
+export const ColorScheme = meta.story({
+ name: "Light/dark mode",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Follows `prefers-color-scheme` automatically — toggle your OS or browser dark mode to see it react live. The button below swaps which light/dark icon *pair* is in use (the other axis `createFaviconScheme` reacts to), independent of the OS setting.",
+ },
+ },
+ },
+ render: () => {
+ const [setIndex, setSetIndex] = createSignal(0);
+ const favicon = createFaviconScheme(() => ICON_SETS[setIndex()]!);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/packages/favicon/stories/tsconfig.json b/packages/favicon/stories/tsconfig.json
new file mode 100644
index 000000000..0cd588e9e
--- /dev/null
+++ b/packages/favicon/stories/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../../.storybook/tsconfig.json",
+ "include": ["./**/*"]
+}
diff --git a/packages/favicon/test/animation.test.ts b/packages/favicon/test/animation.test.ts
new file mode 100644
index 000000000..ce8cbc818
--- /dev/null
+++ b/packages/favicon/test/animation.test.ts
@@ -0,0 +1,203 @@
+import "./setup.js";
+import { createRoot, createSignal, flush } from "solid-js";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
+import { makeFaviconAnimation, createFaviconAnimation } from "../src/index.js";
+
+const frames = ["/f1.png", "/f2.png", "/f3.png"];
+
+beforeAll(() => {
+ vi.useFakeTimers();
+});
+
+beforeEach(() => {
+ vi.clearAllTimers();
+});
+
+afterEach(() => {
+ document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
+});
+
+afterAll(() => {
+ vi.useRealTimers();
+});
+
+describe("makeFaviconAnimation", () => {
+ test("autoplays and cycles frames on an interval", () => {
+ const anim = makeFaviconAnimation(frames, { interval: 100 });
+ expect(anim.frame).toBe(0);
+ expect(anim.playing).toBe(true);
+
+ vi.advanceTimersByTime(100);
+ expect(anim.frame).toBe(1);
+
+ vi.advanceTimersByTime(100);
+ expect(anim.frame).toBe(2);
+
+ // loops back to the start by default
+ vi.advanceTimersByTime(100);
+ expect(anim.frame).toBe(0);
+
+ anim.dispose();
+ });
+
+ test("loop: false stops on the last frame", () => {
+ const anim = makeFaviconAnimation(frames, { interval: 100, loop: false });
+
+ vi.advanceTimersByTime(100);
+ vi.advanceTimersByTime(100);
+ expect(anim.frame).toBe(2);
+ expect(anim.playing).toBe(true);
+
+ vi.advanceTimersByTime(100);
+ expect(anim.frame).toBe(2);
+ expect(anim.playing).toBe(false);
+
+ anim.dispose();
+ });
+
+ test("pause stops advancing, play resumes", () => {
+ const anim = makeFaviconAnimation(frames, { interval: 100 });
+ anim.pause();
+ expect(anim.playing).toBe(false);
+
+ vi.advanceTimersByTime(300);
+ expect(anim.frame).toBe(0);
+
+ anim.play();
+ vi.advanceTimersByTime(100);
+ expect(anim.frame).toBe(1);
+
+ anim.dispose();
+ });
+
+ test("autoplay: false does not start the interval", () => {
+ const anim = makeFaviconAnimation(frames, { interval: 100, autoplay: false });
+ expect(anim.playing).toBe(false);
+ vi.advanceTimersByTime(500);
+ expect(anim.frame).toBe(0);
+ anim.dispose();
+ });
+
+ test("dispose restores the previous favicon and stops the interval", () => {
+ const anim = makeFaviconAnimation(frames, { interval: 100 });
+ anim.dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ // shouldn't throw / advance a disposed timer
+ vi.advanceTimersByTime(1000);
+ });
+
+ test("a single frame never starts an interval", () => {
+ const anim = makeFaviconAnimation(["/only.png"], { interval: 100 });
+ expect(anim.playing).toBe(false);
+ anim.dispose();
+ });
+});
+
+describe("createFaviconAnimation", () => {
+ test("cycles frames reactively and restores on cleanup", () => {
+ const { spinner, dispose } = createRoot(dispose => ({
+ spinner: createFaviconAnimation(frames, { interval: 100 }),
+ dispose,
+ }));
+ flush();
+ expect(spinner.frame()).toBe(0);
+ expect(spinner.playing()).toBe(true);
+
+ vi.advanceTimersByTime(100);
+ flush();
+ expect(spinner.frame()).toBe(1);
+
+ dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ });
+
+ test("play/pause update the playing signal", () => {
+ const { spinner, dispose } = createRoot(dispose => ({
+ spinner: createFaviconAnimation(frames, { interval: 100 }),
+ dispose,
+ }));
+ spinner.pause();
+ flush();
+ expect(spinner.playing()).toBe(false);
+
+ vi.advanceTimersByTime(300);
+ flush();
+ expect(spinner.frame()).toBe(0);
+
+ spinner.play();
+ flush();
+ expect(spinner.playing()).toBe(true);
+ vi.advanceTimersByTime(100);
+ flush();
+ expect(spinner.frame()).toBe(1);
+
+ dispose();
+ });
+
+ test("swapping the frames accessor restarts the cycle", () => {
+ const [list, setList] = createSignal(frames);
+ const { spinner, dispose } = createRoot(dispose => ({
+ spinner: createFaviconAnimation(list, { interval: 100 }),
+ dispose,
+ }));
+
+ vi.advanceTimersByTime(100);
+ flush();
+ expect(spinner.frame()).toBe(1);
+
+ setList(["/x.png", "/y.png"]);
+ flush();
+ expect(spinner.frame()).toBe(0);
+
+ dispose();
+ });
+
+ test("auto-pauses on visibilitychange when hidden, resumes when visible", () => {
+ const { spinner, dispose } = createRoot(dispose => ({
+ spinner: createFaviconAnimation(frames, { interval: 100 }),
+ dispose,
+ }));
+ flush();
+ expect(spinner.playing()).toBe(true);
+
+ Object.defineProperty(document, "hidden", { configurable: true, value: true });
+ document.dispatchEvent(new Event("visibilitychange"));
+ flush();
+ expect(spinner.playing()).toBe(false);
+
+ vi.advanceTimersByTime(300);
+ flush();
+ expect(spinner.frame()).toBe(0);
+
+ Object.defineProperty(document, "hidden", { configurable: true, value: false });
+ document.dispatchEvent(new Event("visibilitychange"));
+ flush();
+ expect(spinner.playing()).toBe(true);
+
+ vi.advanceTimersByTime(100);
+ flush();
+ expect(spinner.frame()).toBe(1);
+
+ dispose();
+ Object.defineProperty(document, "hidden", { configurable: true, value: false });
+ });
+
+ test("does not resume on visible if it was manually paused while hidden was false", () => {
+ const { spinner, dispose } = createRoot(dispose => ({
+ spinner: createFaviconAnimation(frames, { interval: 100 }),
+ dispose,
+ }));
+ spinner.pause();
+ flush();
+
+ Object.defineProperty(document, "hidden", { configurable: true, value: true });
+ document.dispatchEvent(new Event("visibilitychange"));
+ Object.defineProperty(document, "hidden", { configurable: true, value: false });
+ document.dispatchEvent(new Event("visibilitychange"));
+ flush();
+
+ expect(spinner.playing()).toBe(false);
+
+ dispose();
+ });
+});
diff --git a/packages/favicon/test/badge.test.ts b/packages/favicon/test/badge.test.ts
new file mode 100644
index 000000000..c80409228
--- /dev/null
+++ b/packages/favicon/test/badge.test.ts
@@ -0,0 +1,183 @@
+import "./setup.js";
+import { createRoot, createSignal, flush } from "solid-js";
+import { afterEach, describe, expect, test } from "vitest";
+import { makeFaviconBadge, createFaviconBadge } from "../src/index.js";
+
+const baseHref = "/base.png";
+
+/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */
+const flushMicrotasks = async (times = 6): Promise => {
+ for (let i = 0; i < times; i++) await Promise.resolve();
+};
+
+afterEach(() => {
+ document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
+});
+
+describe("makeFaviconBadge", () => {
+ test("falsy values (0, false, undefined) render no badge", async () => {
+ for (const value of [0, false, undefined] as const) {
+ const badge = makeFaviconBadge(baseHref, value);
+ await flushMicrotasks();
+ expect(badge.href).toBe(new URL(baseHref, location.href).href);
+ badge.dispose();
+ }
+ });
+
+ test("a number draws a badge with the digits as text", async () => {
+ const badge = makeFaviconBadge(baseHref, 3);
+ await flushMicrotasks();
+ expect(badge.href).toContain("badge=true");
+ expect(badge.href).toContain("text=3");
+ badge.dispose();
+ });
+
+ test("numbers above max clamp to '{max}+'", async () => {
+ const badge = makeFaviconBadge(baseHref, 150, { max: 99 });
+ await flushMicrotasks();
+ expect(badge.href).toContain("text=99+");
+ badge.dispose();
+ });
+
+ test("true renders a dot with no text", async () => {
+ const badge = makeFaviconBadge(baseHref, true);
+ await flushMicrotasks();
+ expect(badge.href).toContain("badge=true");
+ expect(badge.href).toContain("text=;");
+ badge.dispose();
+ });
+
+ test("a string value is shown verbatim", async () => {
+ const badge = makeFaviconBadge(baseHref, "new");
+ await flushMicrotasks();
+ expect(badge.href).toContain("text=new");
+ badge.dispose();
+ });
+
+ test("empty string renders no badge", async () => {
+ const badge = makeFaviconBadge(baseHref, "");
+ await flushMicrotasks();
+ expect(badge.href).toBe(new URL(baseHref, location.href).href);
+ badge.dispose();
+ });
+
+ test("falls back to the base href if the image fails to load", async () => {
+ const badge = makeFaviconBadge("error:missing.png", 3);
+ await flushMicrotasks();
+ expect(badge.href).toBe(new URL("error:missing.png", location.href).href);
+ badge.dispose();
+ });
+
+ test("dispose restores the previous favicon", async () => {
+ const badge = makeFaviconBadge(baseHref, 3);
+ await flushMicrotasks();
+ badge.dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ });
+
+ test("respects custom color and textColor", async () => {
+ const badge = makeFaviconBadge(baseHref, 3, { color: "#000000", textColor: "#111111" });
+ await flushMicrotasks();
+ expect(badge.href).toContain("color=#000000");
+ badge.dispose();
+ });
+
+ test("position moves the badge's center", async () => {
+ const topLeft = makeFaviconBadge(baseHref, 3, { position: "top-left" });
+ await flushMicrotasks();
+ const topLeftHref = topLeft.href;
+ topLeft.dispose();
+
+ const bottomRight = makeFaviconBadge(baseHref, 3, { position: "bottom-right" });
+ await flushMicrotasks();
+ const bottomRightHref = bottomRight.href;
+ bottomRight.dispose();
+
+ expect(topLeftHref).not.toBe(bottomRightHref);
+ });
+});
+
+describe("createFaviconBadge", () => {
+ test("applies the initial badge and restores on cleanup", async () => {
+ let href!: () => string;
+ createRoot(dispose => {
+ href = createFaviconBadge(baseHref, 5);
+ return dispose;
+ });
+ await flushMicrotasks();
+ expect(href()).toContain("text=5");
+ });
+
+ test("redraws when the value accessor changes", async () => {
+ await createRoot(async dispose => {
+ const [count, setCount] = createSignal(1);
+ const href = createFaviconBadge(baseHref, count);
+
+ await flushMicrotasks();
+ expect(href()).toContain("text=1");
+
+ setCount(2);
+ flush();
+ await flushMicrotasks();
+ expect(href()).toContain("text=2");
+
+ dispose();
+ });
+ });
+
+ test("clearing the value removes the badge", async () => {
+ await createRoot(async dispose => {
+ const [count, setCount] = createSignal(4);
+ const href = createFaviconBadge(baseHref, count);
+
+ await flushMicrotasks();
+ expect(href()).toContain("badge=true");
+
+ setCount(undefined);
+ flush();
+ await flushMicrotasks();
+ expect(href()).toBe(new URL(baseHref, location.href).href);
+
+ dispose();
+ });
+ });
+
+ test("only the latest of two rapid value changes is applied", async () => {
+ await createRoot(async dispose => {
+ const [count, setCount] = createSignal(1);
+ const href = createFaviconBadge(baseHref, count);
+
+ await flushMicrotasks();
+ setCount(2);
+ flush();
+ setCount(3);
+ flush();
+ await flushMicrotasks();
+
+ expect(href()).toContain("text=3");
+ expect(href()).not.toContain("text=2");
+
+ dispose();
+ });
+ });
+
+ test("redraws when the href accessor changes (value static)", async () => {
+ await createRoot(async dispose => {
+ const [path, setPath] = createSignal(baseHref);
+ const href = createFaviconBadge(path, 3);
+
+ await flushMicrotasks();
+ const firstHref = href();
+ expect(firstHref).toContain("text=3");
+
+ setPath("/other.png");
+ flush();
+ await flushMicrotasks();
+
+ expect(href()).toContain("text=3");
+ expect(href()).not.toBe(firstHref);
+
+ dispose();
+ });
+ });
+});
diff --git a/packages/favicon/test/components.test.tsx b/packages/favicon/test/components.test.tsx
new file mode 100644
index 000000000..dd5dd7d5f
--- /dev/null
+++ b/packages/favicon/test/components.test.tsx
@@ -0,0 +1,62 @@
+import "./setup.js";
+import { render } from "@solidjs/web";
+import { afterEach, describe, expect, test } from "vitest";
+import { FaviconLink, makeFavicon } from "../src/index.js";
+
+afterEach(() => {
+ document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
+});
+
+describe("FaviconLink", () => {
+ test("renders a link[rel=icon] with the given href", () => {
+ const container = document.createElement("div");
+ const unmount = render(() => , container);
+
+ const link = container.querySelector('link[rel="icon"]');
+ expect(link).not.toBeNull();
+ expect(link?.getAttribute("href")).toBe("/a.png");
+
+ unmount();
+ });
+
+ test("supports a custom rel", () => {
+ const container = document.createElement("div");
+ const unmount = render(
+ () => ,
+ container,
+ );
+
+ expect(container.querySelector('link[rel="apple-touch-icon"]')).not.toBeNull();
+ expect(container.querySelector('link[rel="icon"]')).toBeNull();
+
+ unmount();
+ });
+
+ test("defaults to rel=icon", () => {
+ const container = document.createElement("div");
+ const unmount = render(() => , container);
+
+ expect(container.querySelector('link[rel="icon"]')).not.toBeNull();
+
+ unmount();
+ });
+
+ // The actual feature this component exists for: rendered into the document's real (the
+ // way an SSR framework's document/`` component would), `makeFavicon`'s "reuse an existing
+ // link" lookup finds it and takes over — no coordination beyond both touching the same DOM node.
+ test("composes with makeFavicon: an existing FaviconLink is reused, not duplicated", () => {
+ const unmountLink = render(() => , document.head);
+ expect(document.head.querySelectorAll('link[rel="icon"]').length).toBe(1);
+
+ const favicon = makeFavicon("/updated.png");
+ expect(document.head.querySelectorAll('link[rel="icon"]').length).toBe(1); // reused, not duplicated
+ expect(favicon.href).toBe(new URL("/updated.png", location.href).href);
+
+ favicon.dispose();
+ // restored to what FaviconLink originally rendered
+ const restored = document.head.querySelector('link[rel="icon"]');
+ expect(restored?.href).toBe(new URL("/initial.png", location.href).href);
+
+ unmountLink();
+ });
+});
diff --git a/packages/favicon/test/favicon.test.ts b/packages/favicon/test/favicon.test.ts
new file mode 100644
index 000000000..c6cee998a
--- /dev/null
+++ b/packages/favicon/test/favicon.test.ts
@@ -0,0 +1,98 @@
+import "./setup.js";
+import { createRoot, createSignal, flush } from "solid-js";
+import { describe, expect, test, afterEach } from "vitest";
+import { makeFavicon, createFavicon } from "../src/index.js";
+
+afterEach(() => {
+ document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
+});
+
+describe("makeFavicon", () => {
+ test("creates a link[rel=icon] when none exists, removes it on dispose", () => {
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+
+ const favicon = makeFavicon("/a.png");
+ const link = document.head.querySelector('link[rel="icon"]');
+ expect(link).not.toBeNull();
+ expect(favicon.href).toBe(new URL("/a.png", location.href).href);
+
+ favicon.dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ });
+
+ test("reuses an existing link and restores its href on dispose", () => {
+ const existing = document.createElement("link");
+ existing.rel = "icon";
+ existing.href = "/original.png";
+ document.head.appendChild(existing);
+
+ const favicon = makeFavicon("/new.png");
+ expect(document.head.querySelectorAll('link[rel="icon"]').length).toBe(1);
+ expect(favicon.href).toBe(new URL("/new.png", location.href).href);
+
+ favicon.dispose();
+ expect(existing.href).toBe(new URL("/original.png", location.href).href);
+ existing.remove();
+ });
+
+ test("setHref updates the link", () => {
+ const favicon = makeFavicon("/a.png");
+ favicon.setHref("/b.png");
+ expect(favicon.href).toBe(new URL("/b.png", location.href).href);
+ favicon.dispose();
+ });
+
+ test("supports a custom rel", () => {
+ const favicon = makeFavicon("/touch.png", { rel: "apple-touch-icon" });
+ expect(document.head.querySelector('link[rel="apple-touch-icon"]')).not.toBeNull();
+ favicon.dispose();
+ expect(document.head.querySelector('link[rel="apple-touch-icon"]')).toBeNull();
+ });
+
+ test("nested make calls restore in LIFO order", () => {
+ const outer = makeFavicon("/outer.png");
+ const inner = makeFavicon("/inner.png");
+ expect(inner.href).toBe(new URL("/inner.png", location.href).href);
+
+ inner.dispose();
+ expect(outer.href).toBe(new URL("/outer.png", location.href).href);
+
+ outer.dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ });
+});
+
+describe("createFavicon", () => {
+ test("applies the initial href and restores on cleanup", () => {
+ const href = createRoot(dispose => {
+ const href = createFavicon("/static.png");
+ expect(href()).toBe(new URL("/static.png", location.href).href);
+ dispose();
+ return href;
+ });
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ expect(href()).toBe(new URL("/static.png", location.href).href);
+ });
+
+ test("reactively updates when given an accessor", () => {
+ const [path, setPath] = createSignal("/a.png");
+ const { href, dispose } = createRoot(dispose => ({ href: createFavicon(path), dispose }));
+
+ expect(href()).toBe(new URL("/a.png", location.href).href);
+
+ setPath("/b.png");
+ flush();
+ expect(href()).toBe(new URL("/b.png", location.href).href);
+
+ dispose();
+ });
+
+ test("a static href never sets up an effect", () => {
+ createRoot(dispose => {
+ const href = createFavicon("/static.png");
+ flush();
+ expect(href()).toBe(new URL("/static.png", location.href).href);
+ dispose();
+ });
+ });
+});
diff --git a/packages/favicon/test/progress.test.ts b/packages/favicon/test/progress.test.ts
new file mode 100644
index 000000000..2425ae2e5
--- /dev/null
+++ b/packages/favicon/test/progress.test.ts
@@ -0,0 +1,141 @@
+import "./setup.js";
+import { createRoot, createSignal, flush } from "solid-js";
+import { afterEach, describe, expect, test } from "vitest";
+import { makeFaviconProgress, createFaviconProgress } from "../src/index.js";
+
+const baseHref = "/base.png";
+
+/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */
+const flushMicrotasks = async (times = 6): Promise => {
+ for (let i = 0; i < times; i++) await Promise.resolve();
+};
+
+afterEach(() => {
+ document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
+});
+
+describe("makeFaviconProgress", () => {
+ test("undefined renders no ring", async () => {
+ const favicon = makeFaviconProgress(baseHref, undefined);
+ await flushMicrotasks();
+ expect(favicon.href).toBe(new URL(baseHref, location.href).href);
+ favicon.dispose();
+ });
+
+ test("a number draws a ring", async () => {
+ const favicon = makeFaviconProgress(baseHref, 40);
+ await flushMicrotasks();
+ expect(favicon.href).toContain("ring=true");
+ favicon.dispose();
+ });
+
+ test("0 still draws the track (distinct from undefined)", async () => {
+ const favicon = makeFaviconProgress(baseHref, 0);
+ await flushMicrotasks();
+ expect(favicon.href).toContain("ring=true");
+ expect(favicon.href).toContain("arcs=1"); // track only, no progress arc
+ favicon.dispose();
+ });
+
+ test("a positive value draws both the track and the progress arc", async () => {
+ const favicon = makeFaviconProgress(baseHref, 40);
+ await flushMicrotasks();
+ expect(favicon.href).toContain("arcs=2");
+ favicon.dispose();
+ });
+
+ test("100 sweeps a full circle", async () => {
+ const favicon = makeFaviconProgress(baseHref, 100);
+ await flushMicrotasks();
+ expect(favicon.href).toContain("endAngle=" + (-Math.PI / 2 + Math.PI * 2).toString());
+ favicon.dispose();
+ });
+
+ test("values are clamped to [0, 100]", async () => {
+ // Sequential — makeFaviconProgress shares the one document favicon link, so two
+ // concurrently in-flight instances would race to write the same element's href.
+ const over = makeFaviconProgress(baseHref, 150);
+ await flushMicrotasks();
+ expect(over.href).toContain("arcs=2"); // clamps to 100: track + full progress arc
+ over.dispose();
+
+ const under = makeFaviconProgress(baseHref, -20);
+ await flushMicrotasks();
+ expect(under.href).toContain("arcs=1"); // clamps to 0: track only, no progress arc
+ under.dispose();
+ });
+
+ test("falls back to the base href if the image fails to load", async () => {
+ const favicon = makeFaviconProgress("error:missing.png", 40);
+ await flushMicrotasks();
+ expect(favicon.href).toBe(new URL("error:missing.png", location.href).href);
+ favicon.dispose();
+ });
+
+ test("dispose restores the previous favicon", async () => {
+ const favicon = makeFaviconProgress(baseHref, 40);
+ await flushMicrotasks();
+ favicon.dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ });
+
+ test("respects custom trackColor and color", async () => {
+ const favicon = makeFaviconProgress(baseHref, 40, {
+ trackColor: "#222222",
+ color: "#333333",
+ });
+ await flushMicrotasks();
+ expect(favicon.href).toContain("trackColor=#222222");
+ expect(favicon.href).toContain("ringColor=#333333");
+ favicon.dispose();
+ });
+});
+
+describe("createFaviconProgress", () => {
+ test("applies the initial progress and restores on cleanup", async () => {
+ let href!: () => string;
+ createRoot(dispose => {
+ href = createFaviconProgress(baseHref, 25);
+ return dispose;
+ });
+ await flushMicrotasks();
+ expect(href()).toContain("ring=true");
+ });
+
+ test("redraws when the progress accessor changes", async () => {
+ await createRoot(async dispose => {
+ const [progress, setProgress] = createSignal(10);
+ const href = createFaviconProgress(baseHref, progress);
+
+ await flushMicrotasks();
+ expect(href()).toContain("arcs=2");
+
+ setProgress(undefined);
+ flush();
+ await flushMicrotasks();
+ expect(href()).toBe(new URL(baseHref, location.href).href);
+
+ dispose();
+ });
+ });
+
+ test("redraws when the href accessor changes (progress static)", async () => {
+ await createRoot(async dispose => {
+ const [path, setPath] = createSignal(baseHref);
+ const href = createFaviconProgress(path, 40);
+
+ await flushMicrotasks();
+ const firstHref = href();
+ expect(firstHref).toContain("arcs=2");
+
+ setPath("/other.png");
+ flush();
+ await flushMicrotasks();
+
+ expect(href()).toContain("arcs=2");
+ expect(href()).not.toBe(firstHref);
+
+ dispose();
+ });
+ });
+});
diff --git a/packages/favicon/test/scheme.test.ts b/packages/favicon/test/scheme.test.ts
new file mode 100644
index 000000000..90e8fe969
--- /dev/null
+++ b/packages/favicon/test/scheme.test.ts
@@ -0,0 +1,96 @@
+import "./setup.js";
+import { createRoot, createSignal, flush } from "solid-js";
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { makeFaviconScheme, createFaviconScheme } from "../src/index.js";
+import { setPrefersColorScheme } from "./setup.js";
+
+const ICONS = { light: "/light.png", dark: "/dark.png" };
+
+beforeEach(() => {
+ setPrefersColorScheme("light");
+});
+
+afterEach(() => {
+ document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
+});
+
+describe("makeFaviconScheme", () => {
+ test("starts on light when the OS prefers light", () => {
+ const favicon = makeFaviconScheme(ICONS);
+ expect(favicon.scheme).toBe("light");
+ expect(favicon.href).toBe(new URL(ICONS.light, location.href).href);
+ favicon.dispose();
+ });
+
+ test("starts on dark when the OS prefers dark", () => {
+ setPrefersColorScheme("dark");
+ const favicon = makeFaviconScheme(ICONS);
+ expect(favicon.scheme).toBe("dark");
+ expect(favicon.href).toBe(new URL(ICONS.dark, location.href).href);
+ favicon.dispose();
+ });
+
+ test("switches when the OS preference changes", () => {
+ const favicon = makeFaviconScheme(ICONS);
+ setPrefersColorScheme("dark");
+ expect(favicon.scheme).toBe("dark");
+ expect(favicon.href).toBe(new URL(ICONS.dark, location.href).href);
+
+ setPrefersColorScheme("light");
+ expect(favicon.scheme).toBe("light");
+ expect(favicon.href).toBe(new URL(ICONS.light, location.href).href);
+
+ favicon.dispose();
+ });
+
+ test("dispose restores the previous favicon and stops watching", () => {
+ const favicon = makeFaviconScheme(ICONS);
+ favicon.dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+
+ setPrefersColorScheme("dark");
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ });
+});
+
+describe("createFaviconScheme", () => {
+ test("applies the initial scheme and restores on cleanup", () => {
+ const { favicon, dispose } = createRoot(dispose => ({
+ favicon: createFaviconScheme(ICONS),
+ dispose,
+ }));
+ expect(favicon.scheme()).toBe("light");
+ expect(favicon.href()).toBe(new URL(ICONS.light, location.href).href);
+
+ dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
+ });
+
+ test("reacts to an OS-level scheme change", () => {
+ const { favicon, dispose } = createRoot(dispose => ({
+ favicon: createFaviconScheme(ICONS),
+ dispose,
+ }));
+
+ setPrefersColorScheme("dark");
+ flush();
+ expect(favicon.scheme()).toBe("dark");
+ expect(favicon.href()).toBe(new URL(ICONS.dark, location.href).href);
+
+ dispose();
+ });
+
+ test("reacts to a reactive icons change", () => {
+ const [icons, setIcons] = createSignal(ICONS);
+ const { favicon, dispose } = createRoot(dispose => ({
+ favicon: createFaviconScheme(icons),
+ dispose,
+ }));
+
+ setIcons({ light: "/light-2.png", dark: "/dark-2.png" });
+ flush();
+ expect(favicon.href()).toBe(new URL("/light-2.png", location.href).href);
+
+ dispose();
+ });
+});
diff --git a/packages/favicon/test/server.test.tsx b/packages/favicon/test/server.test.tsx
new file mode 100644
index 000000000..1eff6d593
--- /dev/null
+++ b/packages/favicon/test/server.test.tsx
@@ -0,0 +1,105 @@
+import { describe, expect, test } from "vitest";
+import { renderToString } from "@solidjs/web";
+import {
+ makeFavicon,
+ createFavicon,
+ makeFaviconAnimation,
+ createFaviconAnimation,
+ makeFaviconBadge,
+ createFaviconBadge,
+ makeFaviconScheme,
+ createFaviconScheme,
+ makeFaviconProgress,
+ createFaviconProgress,
+ FaviconLink,
+} from "../src/index.js";
+
+describe("SSR safety", () => {
+ test("makeFavicon returns an inert controller", () => {
+ const favicon = makeFavicon("/a.png");
+ expect(favicon.href).toBe("/a.png");
+ expect(() => favicon.setHref("/b.png")).not.toThrow();
+ expect(() => favicon.dispose()).not.toThrow();
+ });
+
+ test("createFavicon returns a static accessor", () => {
+ const href = createFavicon("/a.png");
+ expect(href()).toBe("/a.png");
+ });
+
+ test("makeFaviconAnimation returns an inert controller", () => {
+ const anim = makeFaviconAnimation(["/a.png", "/b.png"]);
+ expect(anim.href).toBe("/a.png");
+ expect(anim.playing).toBe(false);
+ expect(() => anim.play()).not.toThrow();
+ expect(() => anim.pause()).not.toThrow();
+ expect(() => anim.dispose()).not.toThrow();
+ });
+
+ test("createFaviconAnimation returns inert accessors", () => {
+ const spinner = createFaviconAnimation(["/a.png", "/b.png"]);
+ expect(spinner.frame()).toBe(0);
+ expect(spinner.playing()).toBe(false);
+ expect(() => spinner.play()).not.toThrow();
+ expect(() => spinner.pause()).not.toThrow();
+ });
+
+ test("makeFaviconBadge returns an inert controller", () => {
+ const badge = makeFaviconBadge("/a.png", 3);
+ expect(badge.href).toBe("/a.png");
+ expect(() => badge.dispose()).not.toThrow();
+ });
+
+ test("createFaviconBadge returns a static accessor", () => {
+ const href = createFaviconBadge("/a.png", 3);
+ expect(href()).toBe("/a.png");
+ });
+
+ test("makeFaviconScheme returns an inert light-scheme controller", () => {
+ const favicon = makeFaviconScheme({ light: "/light.png", dark: "/dark.png" });
+ expect(favicon.href).toBe("/light.png");
+ expect(favicon.scheme).toBe("light");
+ expect(() => favicon.setHref("/other.png")).not.toThrow();
+ expect(() => favicon.dispose()).not.toThrow();
+ });
+
+ test("createFaviconScheme returns static light-scheme accessors", () => {
+ const favicon = createFaviconScheme({ light: "/light.png", dark: "/dark.png" });
+ expect(favicon.href()).toBe("/light.png");
+ expect(favicon.scheme()).toBe("light");
+ });
+
+ test("createFaviconScheme lazily re-reads a function icons accessor", () => {
+ let icons = { light: "/light.png", dark: "/dark.png" };
+ const favicon = createFaviconScheme(() => icons);
+ expect(favicon.href()).toBe("/light.png");
+
+ icons = { light: "/light-2.png", dark: "/dark-2.png" };
+ expect(favicon.href()).toBe("/light-2.png");
+ });
+
+ test("makeFaviconProgress returns an inert controller", () => {
+ const favicon = makeFaviconProgress("/a.png", 40);
+ expect(favicon.href).toBe("/a.png");
+ expect(() => favicon.dispose()).not.toThrow();
+ });
+
+ test("createFaviconProgress returns a static accessor", () => {
+ const href = createFaviconProgress("/a.png", 40);
+ expect(href()).toBe("/a.png");
+ });
+
+ test("FaviconLink renders a real into the server-rendered HTML", () => {
+ const html = renderToString(() => );
+ expect(html).toContain('rel="icon"');
+ expect(html).toContain('href="/a.png"');
+ });
+
+ test("FaviconLink + createFaviconScheme together SSR the guessed initial href", () => {
+ const html = renderToString(() => {
+ const scheme = createFaviconScheme({ light: "/light.png", dark: "/dark.png" });
+ return ;
+ });
+ expect(html).toContain('href="/light.png"');
+ });
+});
diff --git a/packages/favicon/test/setup.ts b/packages/favicon/test/setup.ts
new file mode 100644
index 000000000..be9118c0b
--- /dev/null
+++ b/packages/favicon/test/setup.ts
@@ -0,0 +1,151 @@
+/**
+ * jsdom implements neither a real 2D canvas context nor real image decoding. Both are mocked here
+ * so `createFaviconBadge`'s load-then-draw pipeline resolves deterministically: `getContext("2d")`
+ * returns a fake context that records every draw call onto the canvas instance, `toDataURL()`
+ * derives a stable string from that call log, and `HTMLImageElement`'s `src` setter schedules a
+ * microtask that fires `load` (or `error` for a `"error:"`-prefixed src) instead of doing nothing.
+ */
+
+export type CanvasOp = { type: string; args: unknown[] };
+
+declare global {
+ interface HTMLCanvasElement {
+ _ops: CanvasOp[];
+ }
+}
+
+class MockContext2D {
+ fillStyle = "";
+ strokeStyle = "";
+ lineWidth = 1;
+ lineCap = "butt";
+ font = "";
+ textAlign = "start";
+ textBaseline = "alphabetic";
+
+ constructor(private canvas: HTMLCanvasElement) {}
+
+ private record(type: string, args: unknown[]): void {
+ this.canvas._ops.push({ type, args });
+ }
+
+ clearRect(...args: unknown[]): void {
+ this.record("clearRect", args);
+ }
+ drawImage(image: HTMLImageElement, ...rest: unknown[]): void {
+ // Record the loaded image's `src` rather than the element reference itself, so
+ // `toDataURL()` can expose which source a draw actually used.
+ this.record("drawImage", [image.src, ...rest]);
+ }
+ beginPath(): void {
+ this.record("beginPath", []);
+ }
+ arc(...args: unknown[]): void {
+ this.record("arc", args);
+ }
+ fill(): void {
+ this.record("fill", [this.fillStyle]);
+ }
+ stroke(): void {
+ this.record("stroke", [this.strokeStyle]);
+ }
+ fillText(...args: unknown[]): void {
+ this.record("fillText", [...args, this.fillStyle, this.font]);
+ }
+}
+
+HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, id: string) {
+ if (id !== "2d") return null;
+ this._ops ??= [];
+ return new MockContext2D(this) as unknown as CanvasRenderingContext2D;
+} as typeof HTMLCanvasElement.prototype.getContext;
+
+HTMLCanvasElement.prototype.toDataURL = function (this: HTMLCanvasElement) {
+ const ops = this._ops ?? [];
+ const hasBadge = ops.some(op => op.type === "arc") && ops.some(op => op.type === "fill");
+ const fillTextOp = ops.find(op => op.type === "fillText");
+ const text = fillTextOp ? String(fillTextOp.args[0]) : "";
+ const fillOp = ops.find(op => op.type === "fill");
+ const color = fillOp ? String(fillOp.args[0]) : "";
+
+ const strokeOps = ops.filter(op => op.type === "stroke");
+ const hasRing = strokeOps.length > 0;
+ // First stroke is always the track; a second (only drawn when progress > 0) is the arc.
+ const trackColor = strokeOps.length > 0 ? String(strokeOps[0]!.args[0]) : "";
+ const ringColor = strokeOps.length > 0 ? String(strokeOps[strokeOps.length - 1]!.args[0]) : "";
+ const arcOps = ops.filter(op => op.type === "arc");
+ const lastEndAngle = arcOps.length > 0 ? String(arcOps[arcOps.length - 1]!.args[4]) : "";
+ // The badge circle's center — lets tests distinguish `position` corners (progress always
+ // centers its ring, so this is only meaningful for badge's output).
+ const firstArc = arcOps[0];
+ const arcX = firstArc ? String(firstArc.args[0]) : "";
+ const arcY = firstArc ? String(firstArc.args[1]) : "";
+ // Which base image this draw actually used — otherwise two draws of the same value/progress
+ // onto different base hrefs produce byte-identical mock output, masking a redraw that never
+ // happened.
+ const drawImageOp = ops.find(op => op.type === "drawImage");
+ const src = drawImageOp ? String(drawImageOp.args[0]) : "";
+
+ return (
+ `data:mock;w=${this.width};h=${this.height};badge=${hasBadge};text=${text};color=${color};` +
+ `ring=${hasRing};trackColor=${trackColor};ringColor=${ringColor};arcs=${arcOps.length};` +
+ `endAngle=${lastEndAngle};arcX=${arcX};arcY=${arcY};src=${src}`
+ );
+};
+
+const nativeSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src")!;
+
+Object.defineProperty(HTMLImageElement.prototype, "src", {
+ configurable: true,
+ get(this: HTMLImageElement) {
+ return nativeSrcDescriptor.get!.call(this);
+ },
+ set(this: HTMLImageElement, value: string) {
+ nativeSrcDescriptor.set!.call(this, value);
+ queueMicrotask(() => {
+ if (value.startsWith("error:")) {
+ this.dispatchEvent(new Event("error"));
+ return;
+ }
+ Object.defineProperty(this, "naturalWidth", { value: 32, configurable: true });
+ Object.defineProperty(this, "naturalHeight", { value: 32, configurable: true });
+ this.dispatchEvent(new Event("load"));
+ });
+ },
+});
+
+/**
+ * jsdom doesn't implement `matchMedia` at all. This mock tracks a `matches` flag per query string
+ * and lets tests flip it with `setPrefersColorScheme`, dispatching `change` to every live listener
+ * — `createFaviconScheme`'s only interaction with the platform.
+ */
+class MockMediaQueryList extends EventTarget implements MediaQueryList {
+ onchange: ((this: MediaQueryList, ev: MediaQueryListEvent) => void) | null = null;
+ constructor(public media: string) {
+ super();
+ }
+ get matches(): boolean {
+ return mediaQueryState.get(this.media) ?? false;
+ }
+ addListener(): void {}
+ removeListener(): void {}
+}
+
+const mediaQueryState = new Map();
+const liveMediaQueries: MockMediaQueryList[] = [];
+
+window.matchMedia = ((query: string) => {
+ const mql = new MockMediaQueryList(query);
+ liveMediaQueries.push(mql);
+ return mql;
+}) as typeof window.matchMedia;
+
+/** Test helper: simulate an OS/browser `prefers-color-scheme` change. */
+export function setPrefersColorScheme(scheme: "light" | "dark"): void {
+ const query = "(prefers-color-scheme: dark)";
+ mediaQueryState.set(query, scheme === "dark");
+ for (const mql of liveMediaQueries) {
+ if (mql.media !== query) continue;
+ mql.dispatchEvent(Object.assign(new Event("change"), { matches: mql.matches, media: query }));
+ }
+}
diff --git a/packages/favicon/tsconfig.json b/packages/favicon/tsconfig.json
new file mode 100644
index 000000000..674e85d9a
--- /dev/null
+++ b/packages/favicon/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": ["src"]
+}
\ No newline at end of file
diff --git a/patches/storybook-solidjs-vite@10.5.2.patch b/patches/storybook-solidjs-vite@10.5.2.patch
new file mode 100644
index 000000000..af44c3d17
--- /dev/null
+++ b/patches/storybook-solidjs-vite@10.5.2.patch
@@ -0,0 +1,27 @@
+diff --git a/dist/chunk-TKOWB3JL.js b/dist/chunk-TKOWB3JL.js
+index 532f0e8b2821bfe58827699672a5a82dffd9943c..5fb43fc0f74aeb5dbfe59798d32f62892fa6edaa 100644
+--- a/dist/chunk-TKOWB3JL.js
++++ b/dist/chunk-TKOWB3JL.js
+@@ -1,7 +1,12 @@
+ import { watch } from 'fs';
+ import * as path2 from 'path';
+ import { logger } from 'storybook/internal/node-logger';
+-import ts from 'typescript';
++// Patched: the workspace's own `typescript` devDependency is pinned to 7.x (a deliberate
++// upgrade), whose `typescript` package export is just `{ version }` — none of the classic
++// compiler API (ts.JsxEmit, ts.sys, ts.LanguageService, ...) this file needs. Import a
++// known-good classic TypeScript directly by its stable pnpm store path instead of relying on
++// module resolution, which would otherwise pick up the incompatible hoisted 7.x.
++import ts from '../../../../typescript@6.0.3/node_modules/typescript/lib/typescript.js';
+ import { createLanguage, FileMap } from '@volar/language-core';
+ import { resolveFileLanguageId, createLanguageServiceHost } from '@volar/typescript';
+
+@@ -798,7 +803,7 @@ async function getOrCreateSolidComponentMetaManager(watchMode = false) {
+ return watchManager;
+ }
+ try {
+- const ts2 = await import('typescript');
++ const ts2 = await import('../../../../typescript@6.0.3/node_modules/typescript/lib/typescript.js');
+ const instance = new SolidComponentMetaManager(ts2);
+ if (watchMode) {
+ watchManager = instance;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 41e03ca53..ba916e360 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ overrides:
react: 19.2.7
react-dom: 19.2.7
+patchedDependencies:
+ storybook-solidjs-vite@10.5.2: 5f26b4cdff9127b151770f5c4d3163ddc74e7e57f4506f48627d52541fd64c9e
+
importers:
.:
@@ -125,7 +128,7 @@ importers:
version: 10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
storybook-solidjs-vite:
specifier: ^10.5.2
- version: 10.5.2(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0))
+ version: 10.5.2(patch_hash=5f26b4cdff9127b151770f5c4d3163ddc74e7e57f4506f48627d52541fd64c9e)(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0))
tsdown:
specifier: 'catalog:'
version: 0.22.3(oxc-resolver@11.20.0)(tsx@4.20.2)(typescript@7.0.2)
@@ -434,6 +437,22 @@ importers:
specifier: 'catalog:'
version: 2.0.0-beta.24
+ packages/favicon:
+ dependencies:
+ '@solid-primitives/event-listener':
+ specifier: workspace:^
+ version: link:../event-listener
+ '@solid-primitives/utils':
+ specifier: workspace:^
+ version: link:../utils
+ devDependencies:
+ '@solidjs/web':
+ specifier: 'catalog:'
+ version: 2.0.0-beta.24(solid-js@2.0.0-beta.24)
+ solid-js:
+ specifier: 'catalog:'
+ version: 2.0.0-beta.24
+
packages/fetch:
dependencies:
node-fetch:
@@ -17648,7 +17667,7 @@ snapshots:
std-env@4.1.0: {}
- storybook-solidjs-vite@10.5.2(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)):
+ storybook-solidjs-vite@10.5.2(patch_hash=5f26b4cdff9127b151770f5c4d3163ddc74e7e57f4506f48627d52541fd64c9e)(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)):
dependencies:
'@storybook/builder-vite': 10.4.4(esbuild@0.28.1)(rollup@4.61.0)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0))
'@storybook/global': 5.0.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0039a88a8..43a55186f 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -35,3 +35,6 @@ catalogs:
peer:
solid-js: ^2.0.0-beta.24
'@solidjs/web': ^2.0.0-beta.24
+
+patchedDependencies:
+ storybook-solidjs-vite@10.5.2: patches/storybook-solidjs-vite@10.5.2.patch
diff --git a/template/stories/tsconfig.json b/template/stories/tsconfig.json
index 7a2dd2d82..0cd588e9e 100644
--- a/template/stories/tsconfig.json
+++ b/template/stories/tsconfig.json
@@ -1,4 +1,4 @@
{
- "extends": "../../.storybook/tsconfig.json",
+ "extends": "../../../.storybook/tsconfig.json",
"include": ["./**/*"]
}
From 162a149b703575f596e2b39594d9efa1d19a8ffc Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 09:21:12 -0300
Subject: [PATCH 02/11] Fix tests
---
packages/favicon/test/badge.test.ts | 13 +++++++++----
packages/favicon/test/progress.test.ts | 13 +++++++++----
packages/favicon/test/scheme.test.ts | 3 ++-
packages/favicon/test/setup.ts | 21 ++++++++++++++++-----
4 files changed, 36 insertions(+), 14 deletions(-)
diff --git a/packages/favicon/test/badge.test.ts b/packages/favicon/test/badge.test.ts
index c80409228..2afa57d2e 100644
--- a/packages/favicon/test/badge.test.ts
+++ b/packages/favicon/test/badge.test.ts
@@ -5,10 +5,15 @@ import { makeFaviconBadge, createFaviconBadge } from "../src/index.js";
const baseHref = "/base.png";
-/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */
-const flushMicrotasks = async (times = 6): Promise => {
- for (let i = 0; i < times; i++) await Promise.resolve();
-};
+/**
+ * Lets the load-image microtask, the async draw chain, and its `.then` settle. Waits for a real
+ * macrotask boundary rather than a fixed count of microtask ticks — the render pipeline is
+ * entirely microtask-driven, and every microtask queued anywhere (including by unrelated
+ * concurrently-running test files when this suite runs as part of the full monorepo test run) is
+ * guaranteed to drain before a `setTimeout` callback fires, so this can't under-flush under
+ * contention the way a fixed tick count can.
+ */
+const flushMicrotasks = (): Promise => new Promise(resolve => setTimeout(resolve, 0));
afterEach(() => {
document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
diff --git a/packages/favicon/test/progress.test.ts b/packages/favicon/test/progress.test.ts
index 2425ae2e5..b47365286 100644
--- a/packages/favicon/test/progress.test.ts
+++ b/packages/favicon/test/progress.test.ts
@@ -5,10 +5,15 @@ import { makeFaviconProgress, createFaviconProgress } from "../src/index.js";
const baseHref = "/base.png";
-/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */
-const flushMicrotasks = async (times = 6): Promise => {
- for (let i = 0; i < times; i++) await Promise.resolve();
-};
+/**
+ * Lets the load-image microtask, the async draw chain, and its `.then` settle. Waits for a real
+ * macrotask boundary rather than a fixed count of microtask ticks — the render pipeline is
+ * entirely microtask-driven, and every microtask queued anywhere (including by unrelated
+ * concurrently-running test files when this suite runs as part of the full monorepo test run) is
+ * guaranteed to drain before a `setTimeout` callback fires, so this can't under-flush under
+ * contention the way a fixed tick count can.
+ */
+const flushMicrotasks = (): Promise => new Promise(resolve => setTimeout(resolve, 0));
afterEach(() => {
document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
diff --git a/packages/favicon/test/scheme.test.ts b/packages/favicon/test/scheme.test.ts
index 90e8fe969..b252edc53 100644
--- a/packages/favicon/test/scheme.test.ts
+++ b/packages/favicon/test/scheme.test.ts
@@ -2,11 +2,12 @@ import "./setup.js";
import { createRoot, createSignal, flush } from "solid-js";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { makeFaviconScheme, createFaviconScheme } from "../src/index.js";
-import { setPrefersColorScheme } from "./setup.js";
+import { installMatchMediaMock, setPrefersColorScheme } from "./setup.js";
const ICONS = { light: "/light.png", dark: "/dark.png" };
beforeEach(() => {
+ installMatchMediaMock();
setPrefersColorScheme("light");
});
diff --git a/packages/favicon/test/setup.ts b/packages/favicon/test/setup.ts
index be9118c0b..67a90117e 100644
--- a/packages/favicon/test/setup.ts
+++ b/packages/favicon/test/setup.ts
@@ -134,11 +134,22 @@ class MockMediaQueryList extends EventTarget implements MediaQueryList {
const mediaQueryState = new Map();
const liveMediaQueries: MockMediaQueryList[] = [];
-window.matchMedia = ((query: string) => {
- const mql = new MockMediaQueryList(query);
- liveMediaQueries.push(mql);
- return mql;
-}) as typeof window.matchMedia;
+/**
+ * Installs the `window.matchMedia` mock. Exported so `scheme.test.ts` can re-run it in a
+ * `beforeEach` — this file's other mocks are simple prototype overrides that no other package
+ * touches, but `window.matchMedia` is also assigned by other packages' test suites (`media`,
+ * `a11y`) sharing this same global in the monorepo's non-isolated (`isolate: false`) test run;
+ * re-installing per-test guards against whichever of them runs last in the shared worker.
+ */
+export function installMatchMediaMock(): void {
+ window.matchMedia = ((query: string) => {
+ const mql = new MockMediaQueryList(query);
+ liveMediaQueries.push(mql);
+ return mql;
+ }) as typeof window.matchMedia;
+}
+
+installMatchMediaMock();
/** Test helper: simulate an OS/browser `prefers-color-scheme` change. */
export function setPrefersColorScheme(scheme: "light" | "dark"): void {
From 9da1b6927db06952facd458551a07cabd28182e4 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:06:02 -0300
Subject: [PATCH 03/11] =?UTF-8?q?Frames=20growing=20from=20=E2=89=A41=20to?=
=?UTF-8?q?=20many=20never=20resumes=20playback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/favicon/src/animation.ts | 6 +-
packages/favicon/test/animation.test.ts | 44 ++++++++
packages/favicon/test/badge.test.ts | 23 ++--
packages/favicon/test/progress.test.ts | 23 ++--
packages/favicon/test/setup.ts | 136 ++++++++++++++----------
5 files changed, 155 insertions(+), 77 deletions(-)
diff --git a/packages/favicon/src/animation.ts b/packages/favicon/src/animation.ts
index 961018d7f..556e89f2b 100644
--- a/packages/favicon/src/animation.ts
+++ b/packages/favicon/src/animation.ts
@@ -185,7 +185,11 @@ export function createFaviconAnimation(
list = newList;
setFrame(0);
favicon.setHref(list[0] ?? "");
- if (untrack(playing) && list.length <= 1) pause();
+ if (list.length <= 1) {
+ if (untrack(playing)) pause();
+ } else if (autoplay && !untrack(playing)) {
+ play();
+ }
},
{ defer: true },
);
diff --git a/packages/favicon/test/animation.test.ts b/packages/favicon/test/animation.test.ts
index ce8cbc818..74854de88 100644
--- a/packages/favicon/test/animation.test.ts
+++ b/packages/favicon/test/animation.test.ts
@@ -152,6 +152,50 @@ describe("createFaviconAnimation", () => {
dispose();
});
+ test("shrinking to one frame pauses; growing back past one resumes when autoplay is set", () => {
+ const [list, setList] = createSignal(frames);
+ const { spinner, dispose } = createRoot(dispose => ({
+ spinner: createFaviconAnimation(list, { interval: 100 }),
+ dispose,
+ }));
+ flush();
+ expect(spinner.playing()).toBe(true);
+
+ setList(["/solo.png"]);
+ flush();
+ expect(spinner.playing()).toBe(false);
+
+ vi.advanceTimersByTime(300);
+ flush();
+ expect(spinner.frame()).toBe(0); // no interval running while at one frame
+
+ setList(["/x.png", "/y.png"]);
+ flush();
+ expect(spinner.playing()).toBe(true);
+
+ vi.advanceTimersByTime(100);
+ flush();
+ expect(spinner.frame()).toBe(1);
+
+ dispose();
+ });
+
+ test("growing past one frame does not autoplay when autoplay is false", () => {
+ const [list, setList] = createSignal(["/solo.png"]);
+ const { spinner, dispose } = createRoot(dispose => ({
+ spinner: createFaviconAnimation(list, { interval: 100, autoplay: false }),
+ dispose,
+ }));
+ flush();
+ expect(spinner.playing()).toBe(false);
+
+ setList(["/x.png", "/y.png"]);
+ flush();
+ expect(spinner.playing()).toBe(false);
+
+ dispose();
+ });
+
test("auto-pauses on visibilitychange when hidden, resumes when visible", () => {
const { spinner, dispose } = createRoot(dispose => ({
spinner: createFaviconAnimation(frames, { interval: 100 }),
diff --git a/packages/favicon/test/badge.test.ts b/packages/favicon/test/badge.test.ts
index 2afa57d2e..81ae1704f 100644
--- a/packages/favicon/test/badge.test.ts
+++ b/packages/favicon/test/badge.test.ts
@@ -1,19 +1,22 @@
import "./setup.js";
import { createRoot, createSignal, flush } from "solid-js";
-import { afterEach, describe, expect, test } from "vitest";
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { makeFaviconBadge, createFaviconBadge } from "../src/index.js";
+import { installCanvasMock, installImageMock } from "./setup.js";
const baseHref = "/base.png";
-/**
- * Lets the load-image microtask, the async draw chain, and its `.then` settle. Waits for a real
- * macrotask boundary rather than a fixed count of microtask ticks — the render pipeline is
- * entirely microtask-driven, and every microtask queued anywhere (including by unrelated
- * concurrently-running test files when this suite runs as part of the full monorepo test run) is
- * guaranteed to drain before a `setTimeout` callback fires, so this can't under-flush under
- * contention the way a fixed tick count can.
- */
-const flushMicrotasks = (): Promise => new Promise(resolve => setTimeout(resolve, 0));
+/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */
+const flushMicrotasks = async (times = 10): Promise => {
+ for (let i = 0; i < times; i++) await Promise.resolve();
+};
+
+// Re-installed per test: see the module doc in `setup.ts` for why this file can't rely on that
+// module's own import-time side effects alone.
+beforeEach(() => {
+ installCanvasMock();
+ installImageMock();
+});
afterEach(() => {
document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
diff --git a/packages/favicon/test/progress.test.ts b/packages/favicon/test/progress.test.ts
index b47365286..01f174537 100644
--- a/packages/favicon/test/progress.test.ts
+++ b/packages/favicon/test/progress.test.ts
@@ -1,19 +1,22 @@
import "./setup.js";
import { createRoot, createSignal, flush } from "solid-js";
-import { afterEach, describe, expect, test } from "vitest";
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { makeFaviconProgress, createFaviconProgress } from "../src/index.js";
+import { installCanvasMock, installImageMock } from "./setup.js";
const baseHref = "/base.png";
-/**
- * Lets the load-image microtask, the async draw chain, and its `.then` settle. Waits for a real
- * macrotask boundary rather than a fixed count of microtask ticks — the render pipeline is
- * entirely microtask-driven, and every microtask queued anywhere (including by unrelated
- * concurrently-running test files when this suite runs as part of the full monorepo test run) is
- * guaranteed to drain before a `setTimeout` callback fires, so this can't under-flush under
- * contention the way a fixed tick count can.
- */
-const flushMicrotasks = (): Promise => new Promise(resolve => setTimeout(resolve, 0));
+/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */
+const flushMicrotasks = async (times = 10): Promise => {
+ for (let i = 0; i < times; i++) await Promise.resolve();
+};
+
+// Re-installed per test: see the module doc in `setup.ts` for why this file can't rely on that
+// module's own import-time side effects alone.
+beforeEach(() => {
+ installCanvasMock();
+ installImageMock();
+});
afterEach(() => {
document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove());
diff --git a/packages/favicon/test/setup.ts b/packages/favicon/test/setup.ts
index 67a90117e..62d58e292 100644
--- a/packages/favicon/test/setup.ts
+++ b/packages/favicon/test/setup.ts
@@ -4,6 +4,17 @@
* returns a fake context that records every draw call onto the canvas instance, `toDataURL()`
* derives a stable string from that call log, and `HTMLImageElement`'s `src` setter schedules a
* microtask that fires `load` (or `error` for a `"error:"`-prefixed src) instead of doing nothing.
+ *
+ * All of these are installed both once at import time (below) *and* exported as idempotent
+ * `install*` functions that `badge.test.ts`/`progress.test.ts`/`scheme.test.ts` re-run in their
+ * own `beforeEach`. That's not redundant: under the monorepo's shared, non-isolated
+ * (`isolate: false`) test run, this module's top-level side effects don't reliably re-run for
+ * every file that imports it — some files get a cached module instance whose one-time patching
+ * never touched *their* live globals, silently leaving `HTMLImageElement`/`HTMLCanvasElement`
+ * unmocked (confirmed by instrumentation: `img.src` sets never dispatched `load`/`error` at all,
+ * so the render promise chain hung forever instead of resolving or rejecting). Calling the
+ * exported functions explicitly re-applies the patch to whatever globals are live *right now*,
+ * regardless of whether this module's own top-level code happened to run for this file.
*/
export type CanvasOp = { type: string; args: unknown[] };
@@ -54,65 +65,78 @@ class MockContext2D {
}
}
-HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, id: string) {
- if (id !== "2d") return null;
- this._ops ??= [];
- return new MockContext2D(this) as unknown as CanvasRenderingContext2D;
-} as typeof HTMLCanvasElement.prototype.getContext;
-
-HTMLCanvasElement.prototype.toDataURL = function (this: HTMLCanvasElement) {
- const ops = this._ops ?? [];
- const hasBadge = ops.some(op => op.type === "arc") && ops.some(op => op.type === "fill");
- const fillTextOp = ops.find(op => op.type === "fillText");
- const text = fillTextOp ? String(fillTextOp.args[0]) : "";
- const fillOp = ops.find(op => op.type === "fill");
- const color = fillOp ? String(fillOp.args[0]) : "";
-
- const strokeOps = ops.filter(op => op.type === "stroke");
- const hasRing = strokeOps.length > 0;
- // First stroke is always the track; a second (only drawn when progress > 0) is the arc.
- const trackColor = strokeOps.length > 0 ? String(strokeOps[0]!.args[0]) : "";
- const ringColor = strokeOps.length > 0 ? String(strokeOps[strokeOps.length - 1]!.args[0]) : "";
- const arcOps = ops.filter(op => op.type === "arc");
- const lastEndAngle = arcOps.length > 0 ? String(arcOps[arcOps.length - 1]!.args[4]) : "";
- // The badge circle's center — lets tests distinguish `position` corners (progress always
- // centers its ring, so this is only meaningful for badge's output).
- const firstArc = arcOps[0];
- const arcX = firstArc ? String(firstArc.args[0]) : "";
- const arcY = firstArc ? String(firstArc.args[1]) : "";
- // Which base image this draw actually used — otherwise two draws of the same value/progress
- // onto different base hrefs produce byte-identical mock output, masking a redraw that never
- // happened.
- const drawImageOp = ops.find(op => op.type === "drawImage");
- const src = drawImageOp ? String(drawImageOp.args[0]) : "";
-
- return (
- `data:mock;w=${this.width};h=${this.height};badge=${hasBadge};text=${text};color=${color};` +
- `ring=${hasRing};trackColor=${trackColor};ringColor=${ringColor};arcs=${arcOps.length};` +
- `endAngle=${lastEndAngle};arcX=${arcX};arcY=${arcY};src=${src}`
- );
-};
+/** Installs the `HTMLCanvasElement.prototype.getContext`/`toDataURL` mocks. Idempotent. */
+export function installCanvasMock(): void {
+ HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, id: string) {
+ if (id !== "2d") return null;
+ this._ops ??= [];
+ return new MockContext2D(this) as unknown as CanvasRenderingContext2D;
+ } as typeof HTMLCanvasElement.prototype.getContext;
+
+ HTMLCanvasElement.prototype.toDataURL = function (this: HTMLCanvasElement) {
+ const ops = this._ops ?? [];
+ const hasBadge = ops.some(op => op.type === "arc") && ops.some(op => op.type === "fill");
+ const fillTextOp = ops.find(op => op.type === "fillText");
+ const text = fillTextOp ? String(fillTextOp.args[0]) : "";
+ const fillOp = ops.find(op => op.type === "fill");
+ const color = fillOp ? String(fillOp.args[0]) : "";
+
+ const strokeOps = ops.filter(op => op.type === "stroke");
+ const hasRing = strokeOps.length > 0;
+ // First stroke is always the track; a second (only drawn when progress > 0) is the arc.
+ const trackColor = strokeOps.length > 0 ? String(strokeOps[0]!.args[0]) : "";
+ const ringColor = strokeOps.length > 0 ? String(strokeOps[strokeOps.length - 1]!.args[0]) : "";
+ const arcOps = ops.filter(op => op.type === "arc");
+ const lastEndAngle = arcOps.length > 0 ? String(arcOps[arcOps.length - 1]!.args[4]) : "";
+ // The badge circle's center — lets tests distinguish `position` corners (progress always
+ // centers its ring, so this is only meaningful for badge's output).
+ const firstArc = arcOps[0];
+ const arcX = firstArc ? String(firstArc.args[0]) : "";
+ const arcY = firstArc ? String(firstArc.args[1]) : "";
+ // Which base image this draw actually used — otherwise two draws of the same value/progress
+ // onto different base hrefs produce byte-identical mock output, masking a redraw that never
+ // happened.
+ const drawImageOp = ops.find(op => op.type === "drawImage");
+ const src = drawImageOp ? String(drawImageOp.args[0]) : "";
+
+ return (
+ `data:mock;w=${this.width};h=${this.height};badge=${hasBadge};text=${text};color=${color};` +
+ `ring=${hasRing};trackColor=${trackColor};ringColor=${ringColor};arcs=${arcOps.length};` +
+ `endAngle=${lastEndAngle};arcX=${arcX};arcY=${arcY};src=${src}`
+ );
+ };
+}
+
+installCanvasMock();
const nativeSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src")!;
-Object.defineProperty(HTMLImageElement.prototype, "src", {
- configurable: true,
- get(this: HTMLImageElement) {
- return nativeSrcDescriptor.get!.call(this);
- },
- set(this: HTMLImageElement, value: string) {
- nativeSrcDescriptor.set!.call(this, value);
- queueMicrotask(() => {
- if (value.startsWith("error:")) {
- this.dispatchEvent(new Event("error"));
- return;
- }
- Object.defineProperty(this, "naturalWidth", { value: 32, configurable: true });
- Object.defineProperty(this, "naturalHeight", { value: 32, configurable: true });
- this.dispatchEvent(new Event("load"));
- });
- },
-});
+/** Installs the `HTMLImageElement.prototype.src` load/error simulation mock. Idempotent. */
+export function installImageMock(): void {
+ Object.defineProperty(HTMLImageElement.prototype, "src", {
+ configurable: true,
+ get(this: HTMLImageElement) {
+ return nativeSrcDescriptor.get!.call(this);
+ },
+ set(this: HTMLImageElement, value: string) {
+ nativeSrcDescriptor.set!.call(this, value);
+ // `Promise.resolve().then(...)` rather than `queueMicrotask` — `animation.test.ts`
+ // (elsewhere in this suite) runs under `vi.useFakeTimers()`, which by default also fakes
+ // `queueMicrotask`; native promise scheduling isn't one of the globals fake timers replace.
+ Promise.resolve().then(() => {
+ if (value.startsWith("error:")) {
+ this.dispatchEvent(new Event("error"));
+ return;
+ }
+ Object.defineProperty(this, "naturalWidth", { value: 32, configurable: true });
+ Object.defineProperty(this, "naturalHeight", { value: 32, configurable: true });
+ this.dispatchEvent(new Event("load"));
+ });
+ },
+ });
+}
+
+installImageMock();
/**
* jsdom doesn't implement `matchMedia` at all. This mock tracks a `matches` flag per query string
From 39dddcae7df948a851a29a6d28943920b58fc3fa Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:12:05 -0300
Subject: [PATCH 04/11] Both make* overlay factories fire the async render
without a rejection handler or a disposal guard
---
packages/favicon/src/badge.ts | 5 ++--
packages/favicon/src/canvas.ts | 33 ++++++++++++++++++++++++--
packages/favicon/src/progress.ts | 5 ++--
packages/favicon/test/badge.test.ts | 15 ++++++++++++
packages/favicon/test/progress.test.ts | 15 ++++++++++++
5 files changed, 65 insertions(+), 8 deletions(-)
diff --git a/packages/favicon/src/badge.ts b/packages/favicon/src/badge.ts
index ff2257a4d..be03dc312 100644
--- a/packages/favicon/src/badge.ts
+++ b/packages/favicon/src/badge.ts
@@ -2,7 +2,7 @@ import type { Accessor } from "solid-js";
import { isServer } from "@solidjs/web";
import { type MaybeAccessor, noop } from "@solid-primitives/utils";
import { makeFavicon } from "./favicon.ts";
-import { createCanvasFavicon, drawBaseIcon } from "./canvas.ts";
+import { createCanvasFavicon, drawBaseIcon, guardAsyncRender } from "./canvas.ts";
import type { FaviconController, FaviconOptions } from "./link.ts";
export type FaviconBadgeValue = number | string | boolean | undefined;
@@ -104,8 +104,7 @@ export function makeFaviconBadge(
}
const favicon = makeFavicon(href, options);
- void renderBadgeIcon(href, value, options).then(dataUrl => favicon.setHref(dataUrl));
- return favicon;
+ return guardAsyncRender(favicon, renderBadgeIcon(href, value, options));
}
/**
diff --git a/packages/favicon/src/canvas.ts b/packages/favicon/src/canvas.ts
index b1f2fe698..6e6613046 100644
--- a/packages/favicon/src/canvas.ts
+++ b/packages/favicon/src/canvas.ts
@@ -1,8 +1,8 @@
import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js";
import { isServer } from "@solidjs/web";
-import { access, INTERNAL_OPTIONS, type MaybeAccessor } from "@solid-primitives/utils";
+import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils";
import { makeFavicon } from "./favicon.ts";
-import type { FaviconOptions } from "./link.ts";
+import type { FaviconController, FaviconOptions } from "./link.ts";
/** Loads `src` into an `Image`, resolving once it's decoded (or rejecting on error). */
export function loadImage(src: string): Promise {
@@ -42,6 +42,35 @@ export async function drawBaseIcon(
return { canvas, ctx, size };
}
+/**
+ * Wraps a `make*` canvas-overlay primitive's fire-and-forget render: applies `render`'s result
+ * via `favicon.setHref` once it settles, unless `dispose()` has already run by then — otherwise a
+ * badge/progress draw that resolves after disposal would re-add a removed favicon ``, or
+ * overwrite the previous href `dispose()` just restored, with a stale composite. `render` itself
+ * already falls back to the plain `href` on any internal failure (see `drawBaseIcon`), so a
+ * rejection here is unexpected; treated the same as "leave the current href alone" rather than
+ * risk an unhandled rejection.
+ */
+export function guardAsyncRender(
+ favicon: FaviconController,
+ render: Promise,
+): FaviconController {
+ let disposed = false;
+ render.then(dataUrl => {
+ if (!disposed) favicon.setHref(dataUrl);
+ }, noop);
+ return {
+ get href() {
+ return favicon.href;
+ },
+ setHref: favicon.setHref,
+ dispose() {
+ disposed = true;
+ favicon.dispose();
+ },
+ };
+}
+
/**
* Shared reactive wiring for `create*` primitives that composite a canvas overlay onto a base
* icon (badge, progress ring, …): applies `render(href, value, options)`'s result via
diff --git a/packages/favicon/src/progress.ts b/packages/favicon/src/progress.ts
index 017481dec..006905354 100644
--- a/packages/favicon/src/progress.ts
+++ b/packages/favicon/src/progress.ts
@@ -2,7 +2,7 @@ import type { Accessor } from "solid-js";
import { isServer } from "@solidjs/web";
import { type MaybeAccessor, noop } from "@solid-primitives/utils";
import { makeFavicon } from "./favicon.ts";
-import { createCanvasFavicon, drawBaseIcon } from "./canvas.ts";
+import { createCanvasFavicon, drawBaseIcon, guardAsyncRender } from "./canvas.ts";
import type { FaviconController, FaviconOptions } from "./link.ts";
export type FaviconProgressOptions = {
@@ -80,8 +80,7 @@ export function makeFaviconProgress(
}
const favicon = makeFavicon(href, options);
- void renderProgressIcon(href, progress, options).then(dataUrl => favicon.setHref(dataUrl));
- return favicon;
+ return guardAsyncRender(favicon, renderProgressIcon(href, progress, options));
}
/**
diff --git a/packages/favicon/test/badge.test.ts b/packages/favicon/test/badge.test.ts
index 81ae1704f..0ad0fd022 100644
--- a/packages/favicon/test/badge.test.ts
+++ b/packages/favicon/test/badge.test.ts
@@ -83,6 +83,21 @@ describe("makeFaviconBadge", () => {
expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
});
+ test("a draw that resolves after dispose does not clobber the restored favicon", async () => {
+ const existing = document.createElement("link");
+ existing.rel = "icon";
+ existing.href = "/previous.png";
+ document.head.appendChild(existing);
+
+ const badge = makeFaviconBadge(baseHref, 3);
+ badge.dispose(); // dispose before the async draw (still in flight) has settled
+ await flushMicrotasks();
+
+ expect(document.head.querySelector('link[rel="icon"]')?.href).toBe(
+ new URL("/previous.png", location.href).href,
+ );
+ });
+
test("respects custom color and textColor", async () => {
const badge = makeFaviconBadge(baseHref, 3, { color: "#000000", textColor: "#111111" });
await flushMicrotasks();
diff --git a/packages/favicon/test/progress.test.ts b/packages/favicon/test/progress.test.ts
index 01f174537..442baf122 100644
--- a/packages/favicon/test/progress.test.ts
+++ b/packages/favicon/test/progress.test.ts
@@ -87,6 +87,21 @@ describe("makeFaviconProgress", () => {
expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
});
+ test("a draw that resolves after dispose does not clobber the restored favicon", async () => {
+ const existing = document.createElement("link");
+ existing.rel = "icon";
+ existing.href = "/previous.png";
+ document.head.appendChild(existing);
+
+ const favicon = makeFaviconProgress(baseHref, 40);
+ favicon.dispose(); // dispose before the async draw (still in flight) has settled
+ await flushMicrotasks();
+
+ expect(document.head.querySelector('link[rel="icon"]')?.href).toBe(
+ new URL("/previous.png", location.href).href,
+ );
+ });
+
test("respects custom trackColor and color", async () => {
const favicon = makeFaviconProgress(baseHref, 40, {
trackColor: "#222222",
From c92c20713f08d5533fcc5258e2927a8dc7d2eea6 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:14:14 -0300
Subject: [PATCH 05/11] redraw has no rejection handler, and pending draws can
apply after cleanup
---
packages/favicon/src/canvas.ts | 15 +++++++++++----
packages/favicon/test/badge.test.ts | 17 +++++++++++++++++
packages/favicon/test/progress.test.ts | 17 +++++++++++++++++
3 files changed, 45 insertions(+), 4 deletions(-)
diff --git a/packages/favicon/src/canvas.ts b/packages/favicon/src/canvas.ts
index 6e6613046..58576debb 100644
--- a/packages/favicon/src/canvas.ts
+++ b/packages/favicon/src/canvas.ts
@@ -101,14 +101,15 @@ export function createCanvasFavicon(
let requestId = 0;
const redraw = (h: string, v: Value): void => {
const id = ++requestId;
- void render(h, v, options).then(dataUrl => {
- if (id !== requestId) return; // superseded by a more recent href/value change
+ render(h, v, options).then(dataUrl => {
+ if (id !== requestId) return; // superseded by a more recent href/value change, or disposed
favicon.setHref(dataUrl);
// Read back the DOM-resolved href (browsers resolve relative `href`s to absolute URLs)
// rather than trusting `dataUrl` verbatim, so this always matches the non-reactive `make*`
// variant's `.href` getter.
setCurrent(favicon.href);
- });
+ }, noop); // `render` already falls back to the plain href internally, so a rejection here is
+ // unexpected — nothing to apply beyond avoiding an unhandled rejection.
};
if (typeof href === "function" || typeof value === "function") {
@@ -120,7 +121,13 @@ export function createCanvasFavicon(
redraw(href, value);
}
- onCleanup(favicon.dispose);
+ onCleanup(() => {
+ // Invalidate any redraw still in flight first, reusing the same `requestId` guard `redraw`
+ // checks above — otherwise a draw that settles after disposal could still call
+ // `favicon.setHref`/`setCurrent` on an already-disposed favicon/signal.
+ requestId++;
+ favicon.dispose();
+ });
return current;
}
diff --git a/packages/favicon/test/badge.test.ts b/packages/favicon/test/badge.test.ts
index 0ad0fd022..931519001 100644
--- a/packages/favicon/test/badge.test.ts
+++ b/packages/favicon/test/badge.test.ts
@@ -121,6 +121,23 @@ describe("makeFaviconBadge", () => {
});
describe("createFaviconBadge", () => {
+ test("a draw that resolves after dispose does not clobber the restored favicon", async () => {
+ const existing = document.createElement("link");
+ existing.rel = "icon";
+ existing.href = "/previous.png";
+ document.head.appendChild(existing);
+
+ createRoot(dispose => {
+ createFaviconBadge(baseHref, 5);
+ dispose(); // dispose before the async draw (still in flight) has settled
+ });
+ await flushMicrotasks();
+
+ expect(document.head.querySelector('link[rel="icon"]')?.href).toBe(
+ new URL("/previous.png", location.href).href,
+ );
+ });
+
test("applies the initial badge and restores on cleanup", async () => {
let href!: () => string;
createRoot(dispose => {
diff --git a/packages/favicon/test/progress.test.ts b/packages/favicon/test/progress.test.ts
index 442baf122..50639120d 100644
--- a/packages/favicon/test/progress.test.ts
+++ b/packages/favicon/test/progress.test.ts
@@ -115,6 +115,23 @@ describe("makeFaviconProgress", () => {
});
describe("createFaviconProgress", () => {
+ test("a draw that resolves after dispose does not clobber the restored favicon", async () => {
+ const existing = document.createElement("link");
+ existing.rel = "icon";
+ existing.href = "/previous.png";
+ document.head.appendChild(existing);
+
+ createRoot(dispose => {
+ createFaviconProgress(baseHref, 40);
+ dispose(); // dispose before the async draw (still in flight) has settled
+ });
+ await flushMicrotasks();
+
+ expect(document.head.querySelector('link[rel="icon"]')?.href).toBe(
+ new URL("/previous.png", location.href).href,
+ );
+ });
+
test("applies the initial progress and restores on cleanup", async () => {
let href!: () => string;
createRoot(dispose => {
From 06d807b3f47f7387feca520925aeb2acc81feb84 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:18:41 -0300
Subject: [PATCH 06/11] previousHref is never undefined for a reused link, so
the guard is dead and an attribute-less gets restored to the page URL
---
packages/favicon/src/link.ts | 6 +++++-
packages/favicon/test/favicon.test.ts | 14 ++++++++++++++
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/packages/favicon/src/link.ts b/packages/favicon/src/link.ts
index c1dce436f..2ecd0c9fa 100644
--- a/packages/favicon/src/link.ts
+++ b/packages/favicon/src/link.ts
@@ -23,7 +23,9 @@ export type FaviconController = {
export function bindFaviconLink(rel: FaviconRel, href: string): FaviconController {
let link = document.head.querySelector(`link[rel="${rel}"]`);
const created = link == null;
- const previousHref = link?.href;
+ // The raw attribute, not the `.href` IDL property — the property resolves a missing attribute
+ // to `""` (a real, if empty, value), which would lose the "no href at all" case on restore.
+ const previousHref = link?.getAttribute("href") ?? undefined;
if (!link) {
link = document.createElement("link");
@@ -46,6 +48,8 @@ export function bindFaviconLink(rel: FaviconRel, href: string): FaviconControlle
el.remove();
} else if (previousHref !== undefined) {
el.href = previousHref;
+ } else {
+ el.removeAttribute("href");
}
},
};
diff --git a/packages/favicon/test/favicon.test.ts b/packages/favicon/test/favicon.test.ts
index c6cee998a..d5e558ce7 100644
--- a/packages/favicon/test/favicon.test.ts
+++ b/packages/favicon/test/favicon.test.ts
@@ -35,6 +35,20 @@ describe("makeFavicon", () => {
existing.remove();
});
+ test("reusing an attribute-less existing link restores it without an href on dispose", () => {
+ const existing = document.createElement("link");
+ existing.rel = "icon";
+ document.head.appendChild(existing);
+ expect(existing.hasAttribute("href")).toBe(false);
+
+ const favicon = makeFavicon("/new.png");
+ expect(existing.getAttribute("href")).toBe("/new.png");
+
+ favicon.dispose();
+ expect(existing.hasAttribute("href")).toBe(false);
+ existing.remove();
+ });
+
test("setHref updates the link", () => {
const favicon = makeFavicon("/a.png");
favicon.setHref("/b.png");
From 1bc2d57863e74880f878d53134498a3fae861058 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:23:01 -0300
Subject: [PATCH 07/11] Fix storybook path
---
template/stories/tsconfig.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/template/stories/tsconfig.json b/template/stories/tsconfig.json
index 0cd588e9e..7a2dd2d82 100644
--- a/template/stories/tsconfig.json
+++ b/template/stories/tsconfig.json
@@ -1,4 +1,4 @@
{
- "extends": "../../../.storybook/tsconfig.json",
+ "extends": "../../.storybook/tsconfig.json",
"include": ["./**/*"]
}
From c46e4cd07a3840692c157383f997c53226f052fb Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:39:31 -0300
Subject: [PATCH 08/11] Update stage
---
packages/favicon/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/favicon/package.json b/packages/favicon/package.json
index 8d6b3f28b..85358ca5b 100644
--- a/packages/favicon/package.json
+++ b/packages/favicon/package.json
@@ -15,7 +15,7 @@
},
"primitive": {
"name": "favicon",
- "stage": 0,
+ "stage": 2,
"list": [
"makeFavicon",
"createFavicon",
From 4b2173706f114f36c632a8469426603fb351cdf4 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:41:46 -0300
Subject: [PATCH 09/11] Test doesn't cover the "restores on cleanup" half of
its name, and leaks the root
---
packages/favicon/test/badge.test.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/favicon/test/badge.test.ts b/packages/favicon/test/badge.test.ts
index 931519001..b23e90d6b 100644
--- a/packages/favicon/test/badge.test.ts
+++ b/packages/favicon/test/badge.test.ts
@@ -140,12 +140,16 @@ describe("createFaviconBadge", () => {
test("applies the initial badge and restores on cleanup", async () => {
let href!: () => string;
- createRoot(dispose => {
+ let dispose!: () => void;
+ createRoot(d => {
+ dispose = d;
href = createFaviconBadge(baseHref, 5);
- return dispose;
});
await flushMicrotasks();
expect(href()).toContain("text=5");
+
+ dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
});
test("redraws when the value accessor changes", async () => {
From 5a7ba42fa7ec19e61b813ce8a7a18126d167d982 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:42:24 -0300
Subject: [PATCH 10/11] Dispose the root properly
---
packages/favicon/test/progress.test.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/favicon/test/progress.test.ts b/packages/favicon/test/progress.test.ts
index 50639120d..3c48c5aad 100644
--- a/packages/favicon/test/progress.test.ts
+++ b/packages/favicon/test/progress.test.ts
@@ -134,12 +134,16 @@ describe("createFaviconProgress", () => {
test("applies the initial progress and restores on cleanup", async () => {
let href!: () => string;
- createRoot(dispose => {
+ let dispose!: () => void;
+ createRoot(d => {
+ dispose = d;
href = createFaviconProgress(baseHref, 25);
- return dispose;
});
await flushMicrotasks();
expect(href()).toContain("ring=true");
+
+ dispose();
+ expect(document.head.querySelector('link[rel="icon"]')).toBeNull();
});
test("redraws when the progress accessor changes", async () => {
From 853a43939705ee84b0d91335ff46d4ec13be2d34 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:45:07 -0300
Subject: [PATCH 11/11] Fixed lockfile for deno
---
deno.lock | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/deno.lock b/deno.lock
index b68a629f0..fda88cc32 100644
--- a/deno.lock
+++ b/deno.lock
@@ -3757,7 +3757,7 @@
"integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
"dependencies": [
"browserslist",
- "caniuse-lite@1.0.30001800",
+ "caniuse-lite",
"fraction.js",
"picocolors@1.1.1",
"postcss@8.5.16",
@@ -3769,7 +3769,7 @@
"integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
"dependencies": [
"browserslist",
- "caniuse-lite@1.0.30001800",
+ "caniuse-lite",
"normalize-range",
"num2fraction",
"picocolors@0.2.1",
@@ -3879,7 +3879,7 @@
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
"dependencies": [
"baseline-browser-mapping",
- "caniuse-lite@1.0.30001806",
+ "caniuse-lite",
"electron-to-chromium",
"node-releases",
"update-browserslist-db"
@@ -3928,9 +3928,6 @@
"camelcase-css@2.0.1": {
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="
},
- "caniuse-lite@1.0.30001800": {
- "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="
- },
"caniuse-lite@1.0.30001806": {
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="
},
@@ -5992,7 +5989,6 @@
"@next/env",
"@swc/helpers",
"baseline-browser-mapping",
- "caniuse-lite@1.0.30001800",
"postcss@8.4.31",
"react",
"react-dom",
@@ -8186,6 +8182,14 @@
]
}
},
+ "packages/favicon": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@solidjs/web@2.0.0-beta.20",
+ "npm:solid-js@2.0.0-beta.20"
+ ]
+ }
+ },
"packages/fetch": {
"packageJson": {
"dependencies": [