authorgravatar for murenovich@gmail.comMatt Urenovich <murenovich@gmail.com> 2026-08-09 17:10:49-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-09 21:10:49+00:00
logdeb901d6381e0c86ef326946c8fb45ee323db708
tree1ebe6f444fdab94172efb591e688515a5fe43cf7
parent02f4ce566711e7989e10c2b937c2655ef19188ff
signature Signed by PGP key B5690EEEBB952194

fix(server): favicon resolution no longer pins the event loop (#5538)


2 files changed, 115 insertions(+), 4 deletions(-)

apps/server/src/project/ProjectFaviconResolver.test.ts+103
......@@ -160,6 +160,109 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
160160 }),
161161 );
162162
163 it.effect("resolves icon hrefs from object-literal route metadata", () =>
164 Effect.gen(function* () {
165 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
166 const cwd = yield* makeTempDir;
167 yield* writeTextFile(
168 cwd,
169 "src/routes/__root.tsx",
170 `export const Route = createRootRoute({
171 head: () => ({
172 links: [
173 { rel: "stylesheet", href: "/app.css" },
174 { rel: "icon", href: "/brand/logo.svg" },
175 ],
176 }),
177});`,
178 );
179 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
180
181 const resolved = yield* resolver.resolvePath(cwd);
182
183 expect(resolved).not.toBeNull();
184 expect(resolved).toContain("public/brand/logo.svg");
185 }),
186 );
187
188 it.effect("resolves object-literal icon metadata when href precedes rel", () =>
189 Effect.gen(function* () {
190 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
191 const cwd = yield* makeTempDir;
192 yield* writeTextFile(
193 cwd,
194 "src/root.tsx",
195 `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`,
196 );
197 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
198
199 const resolved = yield* resolver.resolvePath(cwd);
200
201 expect(resolved).not.toBeNull();
202 expect(resolved).toContain("public/brand/logo.svg");
203 }),
204 );
205
206 it.effect("resolves object-literal icon metadata alongside nested objects", () =>
207 Effect.gen(function* () {
208 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
209 const cwd = yield* makeTempDir;
210 yield* writeTextFile(
211 cwd,
212 "src/root.tsx",
213 `const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`,
214 );
215 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
216
217 const resolved = yield* resolver.resolvePath(cwd);
218
219 expect(resolved).not.toBeNull();
220 expect(resolved).toContain("public/brand/logo.svg");
221 }),
222 );
223
224 it.effect("skips icon metadata without an href and keeps scanning", () =>
225 Effect.gen(function* () {
226 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
227 const cwd = yield* makeTempDir;
228 yield* writeTextFile(
229 cwd,
230 "src/root.tsx",
231 `const links = [{ rel: "icon" }, { rel: "icon", href: "/brand/logo.svg" }];`,
232 );
233 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
234
235 const resolved = yield* resolver.resolvePath(cwd);
236
237 expect(resolved).not.toBeNull();
238 expect(resolved).toContain("public/brand/logo.svg");
239 }),
240 );
241
242 // A large icon source with no icon metadata used to pin the server's event loop for
243 // minutes: the object pattern was unanchored, so it restarted at every offset and
244 // rescanned forward from each one. Anchoring keeps this proportional to file size.
245 it.effect("scans large icon sources without an icon in reasonable time", () =>
246 Effect.gen(function* () {
247 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
248 const cwd = yield* makeTempDir;
249 // Mirrors a generated single-file build: large, brace-sparse, and no icon metadata.
250 const filler = `<p>${"pokopia companion guide ".repeat(24)}</p>\n`;
251 yield* writeTextFile(
252 cwd,
253 "index.html",
254 `<!doctype html><html><head><title>guide</title></head><body>\n${filler.repeat(1200)}</body></html>`,
255 );
256
257 const startedAt = performance.now();
258 const resolved = yield* resolver.resolvePath(cwd);
259 const elapsedMs = performance.now() - startedAt;
260
261 expect(resolved).toBeNull();
262 expect(elapsedMs).toBeLessThan(5_000);
263 }),
264 );
265
163266 it.effect("returns null when no icon is present", () =>
164267 Effect.gen(function* () {
165268 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+12-4
......@@ -55,10 +55,13 @@ const ICON_SOURCE_FILES = [
5555] as const;
5656
5757// Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order.
58// The tag pattern is anchored on `<link`, so it only starts at real candidates. Object metadata
59// is matched by scanning brace-free runs instead of by one combined pattern: an unanchored
60// pattern restarts at every offset and rescans forward, which is quadratic on large sources.
5861const LINK_ICON_HTML_RE =
5962 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
60const LINK_ICON_OBJ_RE =
61 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
63const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;
64const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i;
6265
6366export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass<ProjectFaviconResolutionError>()(
6467 "ProjectFaviconResolutionError",
......@@ -99,8 +102,13 @@ export class ProjectFaviconResolver extends Context.Service<
99102function extractIconHref(source: string): string | null {
100103 const htmlMatch = source.match(LINK_ICON_HTML_RE);
101104 if (htmlMatch?.[1]) return htmlMatch[1];
102 const objMatch = source.match(LINK_ICON_OBJ_RE);
103 if (objMatch?.[1]) return objMatch[1];
105 // Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel`
106 // but no href falls through to the next one rather than ending the search.
107 for (const run of source.split("}")) {
108 if (!ICON_REL_RE.test(run)) continue;
109 const hrefMatch = run.match(ICON_HREF_RE);
110 if (hrefMatch?.[1]) return hrefMatch[1];
111 }
104112 return null;
105113}
106114