Skip to content

Commit a3edc18

Browse files
committed
feat: implement unified useAssets css mounting API
1 parent db35533 commit a3edc18

5 files changed

Lines changed: 143 additions & 68 deletions

File tree

packages/start/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ export type {
1010
HandlerOptions,
1111
PageEvent,
1212
ResponseStub,
13-
ServerFunctionMeta,
13+
ServerFunctionMeta
1414
} from "./server/types.ts";
15+
export { useAssets } from "./shared/assets.ts";
1516
export { default as clientOnly } from "./shared/clientOnly.ts";
1617
export { GET } from "./shared/GET.ts";
1718
export { HttpStatusCode } from "./shared/HttpStatusCode.ts";

packages/start/src/server/StartServer.tsx

Lines changed: 7 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,21 @@
11
// @refresh skip
2-
import App from "solid-start:app";
3-
import type { Component, JSX } from "solid-js";
2+
import type { Component } from "solid-js";
43
import {
54
Hydration,
65
HydrationScript,
76
NoHydration,
87
getRequestEvent,
9-
ssr,
10-
useAssets
8+
ssr
119
} from "solid-js/web";
10+
import App from "solid-start:app";
1211

12+
import { useAssets } from "../shared/assets.ts";
1313
import { ErrorBoundary, TopErrorBoundary } from "../shared/ErrorBoundary.tsx";
14-
import { renderAsset } from "./renderAsset.tsx";
15-
import type { Asset, DocumentComponentProps, PageEvent } from "./types.ts";
1614
import { getSsrManifest } from "./manifest/ssr-manifest.ts";
15+
import type { DocumentComponentProps, PageEvent } from "./types.ts";
1716

1817
const docType = ssr("<!DOCTYPE html>");
1918

20-
function matchRoute(matches: any[], routes: any[], matched = []): any[] | undefined {
21-
for (let i = 0; i < routes.length; i++) {
22-
const segment = routes[i];
23-
if (segment.path !== matches[0].path) continue;
24-
let next: any = [...matched, segment];
25-
if (segment.children) {
26-
const nextMatches = matches.slice(1);
27-
if (nextMatches.length === 0) continue;
28-
next = matchRoute(nextMatches, segment.children, next);
29-
if (!next) continue;
30-
}
31-
return next;
32-
}
33-
}
34-
3519
/**
3620
*
3721
* Read more: https://docs.solidjs.com/solid-start/reference/server/start-server
@@ -41,54 +25,14 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
4125

4226
// @ts-ignore
4327
const nonce = context.nonce;
44-
45-
let assets: Asset[] = [];
46-
Promise.resolve()
47-
.then(async () => {
48-
const manifest = getSsrManifest("ssr");
49-
50-
let assetPromises: Promise<Asset[]>[] = [];
51-
// @ts-ignore
52-
if (context.router && context.router.matches) {
53-
// @ts-ignore
54-
const matches = [...context.router.matches];
55-
while (matches.length && (!matches[0].info || !matches[0].info.filesystem)) matches.shift();
56-
const matched = matches.length && matchRoute(matches, context.routes);
57-
if (matched) {
58-
for (let i = 0; i < matched.length; i++) {
59-
const segment = matched[i];
60-
assetPromises.push(manifest.getAssets(segment["$component"].src));
61-
}
62-
} else if (import.meta.env.DEV)
63-
console.warn(
64-
`No route matched for preloading js assets for path ${new URL(context.request.url).pathname}`
65-
);
66-
}
67-
assets = await Promise.all(assetPromises).then(a =>
68-
// dedupe assets
69-
[...new Map(a.flat().map(item => [item.attrs.key, item])).values()].filter(asset =>
70-
import.meta.env.START_ISLANDS
71-
? false
72-
: (asset.attrs as JSX.LinkHTMLAttributes<HTMLLinkElement>).rel === "modulepreload" &&
73-
!context.assets.find((a: Asset) => a.attrs.key === asset.attrs.key)
74-
)
75-
);
76-
})
77-
.catch(console.error);
78-
79-
useAssets(() => (assets.length ? assets.map(m => renderAsset(m)) : undefined));
28+
useAssets(context.assets, nonce);
8029

8130
return (
8231
<NoHydration>
8332
{docType as unknown as any}
8433
<TopErrorBoundary>
8534
<props.document
86-
assets={
87-
<>
88-
<HydrationScript />
89-
{context.assets.map((m: any) => renderAsset(m, nonce))}
90-
</>
91-
}
35+
assets={<HydrationScript />}
9236
scripts={
9337
<>
9438
<script

packages/start/src/server/lazyRoute.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type Component, createComponent, type JSX, lazy, onCleanup } from "solid-js";
2-
2+
import { useAssets } from "../shared/assets.ts";
33
import { type Asset, renderAsset } from "./renderAsset.tsx";
44

55
export default function lazyRoute<T extends Record<string, any>>(
@@ -59,8 +59,9 @@ export default function lazyRoute<T extends Record<string, any>>(
5959
preloadStyles(styles);
6060
}
6161
const Comp: Component<T> = props => {
62+
useAssets(styles);
63+
6264
return [
63-
...styles.map((asset: Asset) => renderAsset(asset)),
6465
createComponent(Component, props)
6566
];
6667
};

packages/start/src/server/spa/StartServer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import type { Component } from "solid-js";
44
import { NoHydration, getRequestEvent, ssr } from "solid-js/web";
55
import { getSsrManifest } from "../manifest/ssr-manifest.ts";
66

7+
import { useAssets } from "../../shared/assets.ts";
78
import { TopErrorBoundary } from "../../shared/ErrorBoundary.tsx";
8-
import { renderAsset } from "../renderAsset.tsx";
99
import type { DocumentComponentProps, PageEvent } from "../types.ts";
1010

1111
const docType = ssr("<!DOCTYPE html>");
@@ -18,12 +18,14 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
1818
const context = getRequestEvent() as PageEvent;
1919
// @ts-ignore
2020
const nonce = context.nonce;
21+
useAssets(context.assets, nonce);
22+
2123
return (
2224
<NoHydration>
2325
{docType as unknown as any}
2426
<TopErrorBoundary>
2527
<props.document
26-
assets={<>{context.assets.map((m: any) => renderAsset(m))}</>}
28+
assets={<></>}
2729
scripts={
2830
<>
2931
<script
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { createRenderEffect, createResource, onCleanup, sharedConfig } from "solid-js";
2+
import { getRequestEvent, isServer, useAssets as useAssets_ } from "solid-js/web";
3+
import { renderAsset, type Asset } from "../server/renderAsset.tsx";
4+
5+
/**
6+
* Keep's Solid suspended while loading CSS
7+
* Prevents FOUC's
8+
*/
9+
const EXPERIMENTAL_CSS_SUSPENSE = true;
10+
11+
/**
12+
* Should also prevent FOUC's (Spoiler alert: it doesn't)
13+
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement/blocking
14+
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLStyleElement/blocking
15+
*/
16+
const EXPERIMENTAL_BLOCKING = true;
17+
18+
const CANCEL_EVENT = "cancel";
19+
const EVENT_REGISTRY = Symbol("assetRegistry");
20+
const NOOP = () => "";
21+
22+
type AssetEntity = { key: string; consumers: number; el?: HTMLElement; ssrIdx?: number };
23+
type Registry = Record<string, AssetEntity>;
24+
type AttrKeys = keyof Asset["attrs"];
25+
26+
const globalRegistry: Registry = {};
27+
28+
const keyAttrs = ["href", "rel", "data-vite-dev-id"] as const;
29+
30+
const getEntity = (registry: Registry, asset: Asset) => {
31+
let key = asset.tag;
32+
for (const k of keyAttrs) {
33+
if (!(k in asset.attrs)) continue;
34+
key += `[${k}='${asset.attrs[k as keyof Asset["attrs"]]}']`;
35+
}
36+
37+
const entity = (registry[key] ??= {
38+
key,
39+
consumers: 0,
40+
el: isServer ? undefined : (document.querySelector("head " + key) as HTMLElement)
41+
});
42+
43+
return entity;
44+
};
45+
46+
export const useAssets = (assets: Asset[], nonce?: string) => {
47+
if (!assets.length) return;
48+
49+
const registry: Registry = isServer
50+
? (getRequestEvent()!.locals[EVENT_REGISTRY] ??= {})
51+
: globalRegistry;
52+
const ssrRequestAssets: Function[] = (sharedConfig.context as any)?.assets;
53+
const cssKeys: string[] = [];
54+
55+
const cssSuspense = EXPERIMENTAL_CSS_SUSPENSE && !sharedConfig.context;
56+
const cssPromises: Promise<any>[] = [];
57+
58+
for (const asset of assets) {
59+
const entity = getEntity(registry, asset);
60+
const isCSSLink = asset.tag === "link" && asset.attrs.rel === "stylesheet";
61+
const isCSS = isCSSLink || asset.tag === "style";
62+
if (isCSS) {
63+
cssKeys.push(entity.key);
64+
}
65+
66+
entity.consumers++;
67+
if (entity.consumers > 1 || entity.el) continue;
68+
69+
// Mounting logic
70+
if (isServer) {
71+
useAssets_(() => renderAsset(asset, nonce));
72+
entity.ssrIdx = ssrRequestAssets.length - 1;
73+
} else {
74+
const el = (entity.el = document.createElement(asset.tag));
75+
76+
if (cssSuspense && isCSSLink) {
77+
cssPromises.push(
78+
new Promise(res => {
79+
el.addEventListener("load", res, { once: true });
80+
el.addEventListener(CANCEL_EVENT, res, { once: true });
81+
el.addEventListener("error", res, { once: true });
82+
})
83+
);
84+
}
85+
86+
if (EXPERIMENTAL_BLOCKING && isCSS) {
87+
el.setAttribute("blocking", "render");
88+
}
89+
90+
for (const k of Object.keys(asset.attrs)) {
91+
if (k === "key") continue;
92+
el.setAttribute(k, asset.attrs[k as AttrKeys]);
93+
}
94+
95+
if ("children" in asset) {
96+
el.innerHTML = asset.children as string;
97+
}
98+
99+
document.head.appendChild(el);
100+
}
101+
}
102+
103+
if (cssSuspense && cssPromises.length) {
104+
const [r] = createResource(() => Promise.all(cssPromises));
105+
createRenderEffect(r);
106+
}
107+
108+
// Unmounting logic
109+
onCleanup(() => {
110+
for (const key of cssKeys) {
111+
const entity = registry[key]!;
112+
entity.consumers--;
113+
if (entity.consumers != 0) {
114+
continue;
115+
}
116+
117+
if (isServer) {
118+
// Ideally this logic should be implemented directly in dom-expressions
119+
ssrRequestAssets.splice(entity.ssrIdx!, 1, NOOP);
120+
} else {
121+
if (EXPERIMENTAL_CSS_SUSPENSE) entity.el!.dispatchEvent(new CustomEvent(CANCEL_EVENT));
122+
entity.el!.remove();
123+
delete registry[key];
124+
}
125+
}
126+
});
127+
};

0 commit comments

Comments
 (0)