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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/many-favicons-bundled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tailor-platform/app-shell": minor
---

AppShell now bundles and injects a small default favicon set instead of a single 32×32 icon. When no `favicon` prop is passed (and the host page declares no `<link rel="icon">`), AppShell renders 16×16 and 32×32 PNG tab icons plus a 180×180 Apple touch icon — all embedded as data URIs, so no asset-copy step is needed. The Apple touch icon covers "Add to Home Screen" on both iOS and Android; the legacy `.ico` and PWA `android-chrome-*` icons are intentionally omitted to keep the bundle small (this is not a PWA).

Behavior for consumers is unchanged: passing `favicon` still replaces the whole set with your single href, and an existing host-page favicon is still respected.
83 changes: 83 additions & 0 deletions packages/core/scripts/gen-favicons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Regenerates `src/lib/default-favicon.ts` from the raw favicon files in
* `src/lib/favicons/`. Each asset is embedded as a base64 data URI so the whole
* favicon set ships in the JS bundle with no asset-copy step in consuming apps.
*
* Run after replacing the source files:
*
* node scripts/gen-favicons.mjs
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const HERE = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = join(HERE, "..", "src", "lib", "favicons");
const OUT = join(HERE, "..", "src", "lib", "default-favicon.ts");

const toDataUri = (file, mime) => {
const b64 = readFileSync(join(SRC_DIR, file)).toString("base64");
return `data:${mime};base64,${b64}`;
};

// Each entry maps a file in ./favicons to the data-URI const it becomes.
// We deliberately bundle only what a browser tab needs on a non-PWA web app:
// the 16/32 PNG tab icons plus the Apple touch icon for iOS home-screen
// bookmarks. The legacy `.ico` and the PWA `android-chrome-*` icons are
// intentionally excluded to keep the bundle small.
const assets = [
{ const: "FAVICON_16", file: "favicon-16x16.png", mime: "image/png" },
{ const: "FAVICON_32", file: "favicon-32x32.png", mime: "image/png" },
{ const: "APPLE_TOUCH_ICON", file: "apple-touch-icon.png", mime: "image/png" },
];

const uris = Object.fromEntries(assets.map((a) => [a.const, toDataUri(a.file, a.mime)]));

const header = `/**
* Bundled default favicons for AppShell.
*
* Historically AppShell shipped a single 32×32 Tailor mark. It now bundles the
* small set a browser tab actually needs on a (non-PWA) web app: the 16×16 and
* 32×32 PNG tab icons plus the 180×180 Apple touch icon for iOS home-screen
* bookmarks. The legacy \`.ico\` and PWA \`android-chrome-*\` icons are omitted to
* keep the bundle small. Each asset is embedded as a base64 data URI so the set
* ships in the JS bundle and needs no asset-copy step in the consuming app.
* \`<DocumentHead>\` injects the entire {@link DEFAULT_FAVICONS} list as \`<link>\`
* tags when a consumer passes no \`favicon\` prop and the host page declares no
* \`<link rel="icon">\`.
*
* Consumers still override the whole set by passing \`favicon\` (any href: a
* public-path URL such as \`/favicon.ico\`, or a data URI).
*
* DO NOT EDIT THE DATA URIS BY HAND. The raw source files live in
* \`./favicons/\`; regenerate this file with \`node scripts/gen-favicons.mjs\`.
*/

/** A single favicon \`<link>\` descriptor rendered by \`<DocumentHead>\`. */
export type FaviconLink = {
/** The link relationship — \`"icon"\` for tab favicons, \`"apple-touch-icon"\` for iOS. */
rel: "icon" | "apple-touch-icon";
/** The favicon href (a bundled data URI, or a consumer-provided URL). */
href: string;
/** MIME type hint so browsers can prioritise the right format. */
type?: string;
/** Pixel dimensions (e.g. \`"32x32"\`) so browsers pick the best-fit icon. */
sizes?: string;
};
`;

const constLines = assets.map((a) => `const ${a.const} =\n "${uris[a.const]}";`).join("\n\n");

const listBody = `/**
* The default favicon set: the 16/32 PNG tab icons plus the Apple touch icon.
*/
export const DEFAULT_FAVICONS: FaviconLink[] = [
{ rel: "icon", type: "image/png", sizes: "16x16", href: FAVICON_16 },
{ rel: "icon", type: "image/png", sizes: "32x32", href: FAVICON_32 },
{ rel: "apple-touch-icon", sizes: "180x180", href: APPLE_TOUCH_ICON },
];
`;

const content = `${header}\n${constLines}\n\n${listBody}`;
writeFileSync(OUT, content);
console.log(`Wrote ${OUT} (${Buffer.byteLength(content)} bytes)`);
46 changes: 34 additions & 12 deletions packages/core/src/components/document-head.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MemoryRouter } from "react-router";
import { AppShellConfigContext, type RootConfiguration } from "@/contexts/appshell-context";
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
import { useOverrideBreadcrumb } from "@/hooks/use-override-breadcrumb";
import { DEFAULT_FAVICON_HREF } from "@/lib/default-favicon";
import { DEFAULT_FAVICONS } from "@/lib/default-favicon";
import { DocumentHead } from "./document-head";

const configurations: RootConfiguration = {
Expand Down Expand Up @@ -50,6 +50,16 @@ const iconType = () =>
const appShellIconHref = () =>
document.querySelector<HTMLLinkElement>("link[data-app-shell-favicon]")?.getAttribute("href");

const appShellIcons = () =>
Array.from(document.querySelectorAll<HTMLLinkElement>("link[data-app-shell-favicon]")).map(
(el) => ({
rel: el.getAttribute("rel"),
href: el.getAttribute("href"),
type: el.getAttribute("type"),
sizes: el.getAttribute("sizes"),
}),
);

const appendStaticIcon = (href: string, rel = "icon") => {
const link = document.createElement("link");
link.setAttribute("rel", rel);
Expand All @@ -61,12 +71,16 @@ const appendStaticIcon = (href: string, rel = "icon") => {
describe("DocumentHead", () => {
beforeEach(() => {
document.title = "initial";
document.head.querySelectorAll('link[rel~="icon"]').forEach((el) => el.remove());
document.head
.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]')
.forEach((el) => el.remove());
});

afterEach(() => {
cleanup();
document.head.querySelectorAll('link[rel~="icon"]').forEach((el) => el.remove());
document.head
.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]')
.forEach((el) => el.remove());
});

it("sets '<page> · <app>' from the leaf segment and app title", async () => {
Expand All @@ -88,13 +102,25 @@ describe("DocumentHead", () => {
it("leaves the document title untouched when nothing resolves", async () => {
renderAt("/");
// Give React a chance to (not) render a <title>.
await waitFor(() => expect(appShellIconHref()).toBe(DEFAULT_FAVICON_HREF));
await waitFor(() => expect(appShellIconHref()).toBe(DEFAULT_FAVICONS[0].href));
expect(document.title).toBe("initial");
});

it("renders the bundled Tailor favicon by default", async () => {
it("renders the full bundled default favicon set by default", async () => {
renderAt("/orders/123", { title: "My App" });
await waitFor(() => expect(appShellIcons()).toHaveLength(DEFAULT_FAVICONS.length));
expect(appShellIcons().map((i) => i.href)).toEqual(DEFAULT_FAVICONS.map((f) => f.href));
});

it("renders each default favicon with its declared rel, type, and sizes", async () => {
renderAt("/orders/123", { title: "My App" });
await waitFor(() => expect(appShellIconHref()).toBe(DEFAULT_FAVICON_HREF));
await waitFor(() => expect(appShellIcons()).toHaveLength(DEFAULT_FAVICONS.length));
for (const fav of DEFAULT_FAVICONS) {
const match = appShellIcons().find((i) => i.href === fav.href);
expect(match?.rel).toBe(fav.rel);
expect(match?.type).toBe(fav.type ?? null);
expect(match?.sizes).toBe(fav.sizes ?? null);
}
});

it("respects a host-page favicon when the prop is omitted", async () => {
Expand All @@ -110,19 +136,15 @@ describe("DocumentHead", () => {

view.rerender(headTree("/orders/123", { title: "My App" }));

await waitFor(() => expect(appShellIconHref()).toBe(DEFAULT_FAVICON_HREF));
await waitFor(() => expect(appShellIcons()).toHaveLength(DEFAULT_FAVICONS.length));
expect(appShellIconHref()).toBe(DEFAULT_FAVICONS[0].href);
});

it("renders a consumer-provided favicon", async () => {
renderAt("/orders/123", { title: "My App", favicon: "/custom.ico" });
await waitFor(() => expect(appShellIconHref()).toBe("/custom.ico"));
});

it("infers type=image/png for the default data URI favicon", async () => {
renderAt("/orders/123", { title: "My App" });
await waitFor(() => expect(iconType()).toBe("image/png"));
});

it("infers type=image/x-icon for .ico favicons", async () => {
renderAt("/orders/123", { title: "My App", favicon: "/favicon.ico" });
await waitFor(() => expect(iconType()).toBe("image/x-icon"));
Expand Down
34 changes: 24 additions & 10 deletions packages/core/src/components/document-head.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAppShellConfig } from "@/contexts/appshell-context";
import { useBreadcrumbOverrideOptional } from "@/contexts/breadcrumb-context";
import { usePathSegments } from "@/components/dynamic-breadcrumb";
import { DEFAULT_FAVICON_HREF } from "@/lib/default-favicon";
import { DEFAULT_FAVICONS, type FaviconLink } from "@/lib/default-favicon";

const SEPARATOR = " · ";
const APP_SHELL_FAVICON_ATTR = "data-app-shell-favicon";
Expand Down Expand Up @@ -35,8 +35,10 @@ const inferFaviconType = (href: string): string | undefined => {
* document keeps whatever title it already had.
* - **Favicon** — the `favicon` prop passed to `<AppShell>`. When that prop is
* omitted, AppShell preserves any existing host-page `<link rel="icon">`
* and only falls back to the bundled Tailor default
* ({@link DEFAULT_FAVICON_HREF}) when no favicon exists yet.
* and only falls back to the bundled default set
* ({@link DEFAULT_FAVICONS} — the 16/32 PNG tab icons plus a 180×180 Apple
* touch icon) when no favicon exists yet. A `favicon` prop replaces the whole
* set with that single href.
*
* Rendered once inside the router (see `createRootRoute`). React 19 hoists the
* `<title>`/`<link>` into `<head>` and updates them on every navigation — no
Expand All @@ -61,20 +63,32 @@ export const DocumentHead = () => {
const hasHostPageFavicon =
typeof document !== "undefined" &&
document.head.querySelector(`link[rel~="icon"]:not([${APP_SHELL_FAVICON_ATTR}])`);
const resolvedFavicon = favicon ?? (hasHostPageFavicon ? undefined : DEFAULT_FAVICON_HREF);
const faviconType = resolvedFavicon ? inferFaviconType(resolvedFavicon) : undefined;

// A `favicon` prop replaces the whole set with that single href; otherwise we
// inject the full bundled default set, but only when the host page hasn't
// already declared its own icon.
let faviconLinks: FaviconLink[];
if (favicon) {
faviconLinks = [{ rel: "icon", href: favicon, type: inferFaviconType(favicon) }];
} else if (hasHostPageFavicon) {
faviconLinks = [];
} else {
faviconLinks = DEFAULT_FAVICONS;
}

return (
<>
{title ? <title>{title}</title> : null}
{resolvedFavicon ? (
{faviconLinks.map(({ rel, href, type, sizes }) => (
<link
rel="icon"
href={resolvedFavicon}
key={`${rel}-${sizes ?? "any"}`}
rel={rel}
href={href}
data-app-shell-favicon=""
{...(faviconType ? { type: faviconType } : {})}
{...(type ? { type } : {})}
{...(sizes ? { sizes } : {})}
/>
) : null}
))}
</>
);
};
Loading
Loading