@lovrozagar/flare 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/flare",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
5
5
  "keywords": [
6
6
  "flare",
@@ -17,6 +17,7 @@ const managedHreflangTags = new Set<string>();
17
17
  /* CSS lifecycle: per-route elements + refcounted dedup for shared stylesheets */
18
18
  const cssElementsByRoute = new Map<string, HTMLElement[]>();
19
19
  const cssRefCount = new Map<string, { element: HTMLElement; refCount: number }>();
20
+ const styleRefCount = new Map<string, { element: HTMLElement; refCount: number }>();
20
21
 
21
22
  export function clearRouteTracking(): void {
22
23
  baseTitle = "";
@@ -26,6 +27,7 @@ export function clearRouteTracking(): void {
26
27
  managedHreflangTags.clear();
27
28
  cssElementsByRoute.clear();
28
29
  cssRefCount.clear();
30
+ styleRefCount.clear();
29
31
  }
30
32
 
31
33
  export function initRouteHierarchy(matchIds: string[], rootTitle?: string): void {
@@ -561,24 +563,72 @@ function addCssLinkForRoute(matchId: string, href: string): void {
561
563
  cssElementsByRoute.set(matchId, list);
562
564
  }
563
565
 
564
- function addCssStyleForRoute(matchId: string, children: string): void {
565
- if (typeof document === "undefined") return;
566
- const el = document.createElement("style");
566
+ function stampStyleNonce(el: HTMLElement): void {
567
+ if (el.getAttribute("nonce") || el.nonce) return;
567
568
  const meta = document.querySelector?.('meta[name="csp-nonce"]');
568
569
  const tagged = document.querySelector?.("script[nonce], style[nonce]") as HTMLElement | null;
569
570
  const nonce = meta?.getAttribute("content") || tagged?.nonce || tagged?.getAttribute("nonce") || "";
570
- if (nonce) {
571
- el.setAttribute("nonce", nonce);
572
- el.nonce = nonce;
571
+ if (!nonce) return;
572
+ el.setAttribute("nonce", nonce);
573
+ el.nonce = nonce;
574
+ }
575
+
576
+ function isInfraStyle(el: HTMLElement): boolean {
577
+ const id = el.getAttribute("id");
578
+ return id === "flare-runtime" || id === "flare-critical";
579
+ }
580
+
581
+ function isStyleTracked(el: HTMLElement): boolean {
582
+ for (const ref of styleRefCount.values()) {
583
+ if (ref.element === el) return true;
573
584
  }
574
- el.setAttribute("data-flare-route", matchId);
575
- el.textContent = children;
576
- document.head.appendChild(el);
585
+ return false;
586
+ }
587
+
588
+ function findAdoptableStyle(children: string): HTMLElement | null {
589
+ const nodes = document.head?.querySelectorAll("style");
590
+ if (!nodes) return null;
591
+ for (const node of nodes) {
592
+ const el = node as HTMLElement;
593
+ if (isInfraStyle(el) || isStyleTracked(el)) continue;
594
+ if (el.textContent === children) return el;
595
+ }
596
+ return null;
597
+ }
598
+
599
+ function trackStyleForRoute(matchId: string, el: HTMLElement): void {
577
600
  const list = cssElementsByRoute.get(matchId) ?? [];
578
601
  list.push(el);
579
602
  cssElementsByRoute.set(matchId, list);
580
603
  }
581
604
 
605
+ function addCssStyleForRoute(matchId: string, children: string): void {
606
+ if (typeof document === "undefined") return;
607
+ const ref = styleRefCount.get(children);
608
+ if (ref) {
609
+ ref.refCount++;
610
+ trackStyleForRoute(matchId, ref.element);
611
+ return;
612
+ }
613
+
614
+ const existing = findAdoptableStyle(children);
615
+ if (existing) {
616
+ existing.setAttribute("data-flare-route", matchId);
617
+ stampStyleNonce(existing);
618
+ styleRefCount.set(children, { element: existing, refCount: 1 });
619
+ trackStyleForRoute(matchId, existing);
620
+ return;
621
+ }
622
+
623
+ const el = document.createElement("style");
624
+ stampStyleNonce(el);
625
+ el.setAttribute("data-flare-route", matchId);
626
+ el.textContent = children;
627
+ document.head.appendChild(el);
628
+ styleRefCount.set(children, { element: el, refCount: 1 });
629
+ trackStyleForRoute(matchId, el);
630
+ }
631
+
582
632
  function applyCssForRoute(matchId: string, config: HeadConfig): void {
583
633
  /* Snapshot old elements before adding new ones */
584
634
  const oldElements = cssElementsByRoute.get(matchId) ?? [];
@@ -608,20 +658,34 @@ function applyCssForRoute(matchId: string, config: HeadConfig): void {
608
658
 
609
659
  /* THEN decrement old refcounts — shared elements stay because new refs were added above */
610
660
  for (const el of oldElements) {
611
- const href = el.getAttribute("href");
612
- if (href && el.getAttribute("rel") === "stylesheet") {
613
- const ref = cssRefCount.get(href);
614
- if (ref) {
615
- ref.refCount--;
616
- if (ref.refCount <= 0) {
617
- ref.element.remove();
618
- cssRefCount.delete(href);
619
- }
620
- continue;
661
+ releaseCssElement(el);
662
+ }
663
+ }
664
+
665
+ function releaseCssElement(el: HTMLElement): void {
666
+ const href = el.getAttribute("href");
667
+ if (href && el.getAttribute("rel") === "stylesheet") {
668
+ const ref = cssRefCount.get(href);
669
+ if (ref) {
670
+ ref.refCount--;
671
+ if (ref.refCount <= 0) {
672
+ ref.element.remove();
673
+ cssRefCount.delete(href);
621
674
  }
675
+ return;
622
676
  }
623
- el.remove();
624
677
  }
678
+ const content = el.textContent ?? "";
679
+ const styleRef = styleRefCount.get(content);
680
+ if (styleRef && styleRef.element === el) {
681
+ styleRef.refCount--;
682
+ if (styleRef.refCount <= 0) {
683
+ styleRef.element.remove();
684
+ styleRefCount.delete(content);
685
+ }
686
+ return;
687
+ }
688
+ el.remove();
625
689
  }
626
690
 
627
691
  function removeCssForRoute(routeId: string): void {
@@ -629,21 +693,7 @@ function removeCssForRoute(routeId: string): void {
629
693
  if (!elements) return;
630
694
 
631
695
  for (const el of elements) {
632
- const href = el.getAttribute("href");
633
- /* Refcounted stylesheet links */
634
- if (href && el.getAttribute("rel") === "stylesheet") {
635
- const ref = cssRefCount.get(href);
636
- if (ref) {
637
- ref.refCount--;
638
- if (ref.refCount <= 0) {
639
- ref.element.remove();
640
- cssRefCount.delete(href);
641
- }
642
- continue;
643
- }
644
- }
645
- /* <style> elements or untracked links — just remove */
646
- el.remove();
696
+ releaseCssElement(el);
647
697
  }
648
698
  cssElementsByRoute.delete(routeId);
649
699
  }
@@ -96,6 +96,8 @@ export interface PipelineResult {
96
96
  auth: unknown | null;
97
97
  deferContexts: Map<string, DeferContext>;
98
98
  matches: PipelineMatch[];
99
+ /** Accumulated merge (root → page) for SSR `resolvedHead`. */
100
+ mergedHead?: HeadConfig;
99
101
  }
100
102
 
101
103
  export interface PipelineMatch {
@@ -237,6 +239,23 @@ async function buildInternalRoutes(
237
239
  return result;
238
240
  }
239
241
 
242
+ /**
243
+ * Per-match head for client apply (`ph`, NDJSON, match cache).
244
+ * Scalars / objects / images / jsonLd stay merged so upsert and
245
+ * remove-all-then-append fields still see the full set. Concatenate
246
+ * `custom.*` arrays stay on the declaring route so descendants do not replay them.
247
+ */
248
+ function toClientHeadConfig(merged: HeadConfig | undefined, own: HeadConfig | undefined): HeadConfig | undefined {
249
+ if (!merged) return undefined;
250
+ const client: HeadConfig = { ...merged };
251
+ if (own?.custom !== undefined) {
252
+ client.custom = own.custom;
253
+ } else {
254
+ delete client.custom;
255
+ }
256
+ return client;
257
+ }
258
+
240
259
  export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>): Promise<PipelineResult> {
241
260
  const { abortController, cause, env, localeConfig, prefetch, request, routes, ssrCacheDefaults, url } = config;
242
261
  const tracer = config.tracer ?? noopTracer;
@@ -605,11 +624,12 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
605
624
  let mergedHead: HeadConfig | undefined;
606
625
 
607
626
  for (const match of matches) {
627
+ let routeHead: HeadConfig | undefined;
608
628
  if (match.route.head && match.status === "success") {
609
629
  const location = routeLocationMap.get(match.route);
610
630
  if (location) {
611
631
  try {
612
- const routeHead = match.route.head({
632
+ routeHead = match.route.head({
613
633
  ...urlHelpers,
614
634
  cause,
615
635
  loaderData: match.loaderData,
@@ -631,10 +651,13 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
631
651
  }
632
652
  } catch (e: unknown) {
633
653
  match.headError = e instanceof Error ? e : new Error(String(e));
654
+ routeHead = undefined;
634
655
  }
635
656
  }
636
657
  }
637
- match.headConfig = mergedHead;
658
+ /* Client apply uses this match's own custom.* so concatenate fields
659
+ * are not replayed once per descendant. SSR uses `mergedHead`. */
660
+ match.headConfig = toClientHeadConfig(mergedHead, routeHead);
638
661
  }
639
662
  headSpan.end();
640
663
 
@@ -693,5 +716,5 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
693
716
  }
694
717
  headersSpan.end();
695
718
 
696
- return { auth, deferContexts, matches };
719
+ return { auth, deferContexts, matches, mergedHead };
697
720
  }
@@ -1214,7 +1214,7 @@ export function createServerHandler<
1214
1214
  });
1215
1215
  }
1216
1216
 
1217
- const resolvedHead = pipelineResult.matches.findLast((m) => m.headConfig)?.headConfig ?? {};
1217
+ const resolvedHead = pipelineResult.mergedHead ?? {};
1218
1218
 
1219
1219
  const fuzzyLogs = enableServerLogs ? getServerLogs() : undefined;
1220
1220
  if (request.headers.get(HEADER_DATA) === HEADER_FLAG) {
@@ -1517,7 +1517,7 @@ export function createServerHandler<
1517
1517
  const logsForTransport = collectedLogs && collectedLogs.length > 0 ? collectedLogs : undefined;
1518
1518
 
1519
1519
  if (!isDataRequest) {
1520
- const resolvedHead = pipelineResult.matches.findLast((m) => m.headConfig)?.headConfig ?? {};
1520
+ const resolvedHead = pipelineResult.mergedHead ?? {};
1521
1521
 
1522
1522
  let ssrResult: ReturnType<typeof renderToStream>;
1523
1523
  setRewrite(composedRewrite);