Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/frames-assets-tier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

frames: the assets tier (frames savings pass C5) — the head mirror a segment's `assets` record drives (the stylesheet gate, module and typed preloads, inline styles) leaves the eager frames client for the lazy chunk `@solidjs/web/frames/assets`, loaded through the tier mechanism. A segment whose assets record carries stylesheets or inline styles is not ready while the tier is not resident: the server's fallback stays on screen and the reveal happens at max(tier load, stylesheet load) — no segment reveals unstyled. Once the tier is resident only stylesheets gate; inline styles, modules and preloads apply at the record's arrival, and a segment with none of these never waits on the tier. New export path `@solidjs/web/frames/assets` (`@experimental`); `InstallOptions.tiers` loaders resolve `TierModule` (a module with or without `install()`).
52 changes: 26 additions & 26 deletions documentation/plans/frames-savings-pass.md

Large diffs are not rendered by default.

212 changes: 212 additions & 0 deletions packages/web/frames/src/assets-tier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* `@solidjs/web/frames/assets` — the frames client's ASSETS tier (frames
* savings pass §3 row C5): what a segment's `seg:<k>:assets` record does to
* the document head, loaded on demand through the tier mechanism
* (`prepareTier("assets")`, frame-client.ts).
*
* What rides in this chunk, and so leaves the eager frames client: the
* import-free mirror of the client asset registry's head conventions
* (`ensureStylesheet` / `ensurePreload` / `ensureModulePreload` /
* `applyInlineStyles`, `findHeadElement`'s attribute-compared lookup and
* head.ts's `qualifierValue`), the per-frame flush a pending stylesheet
* wakes, and the assets pass over a record (modules, typed preloads, inline
* styles). The eager client keeps the record itself (`chunkToRecords`'
* `assets` case, the host's `seg::assets` accumulate), the pass's walk over
* the store, and the reveal's READINESS TERM: a segment whose assets record
* carries stylesheets or inline styles while this tier is not resident is
* not ready — the server's `<Loading>` fallback stays on screen; the reveal
* happens at max(tier load, stylesheet load) (frames savings pass §1,
* "assets": no FOUC, no blank). Once the tier is resident only stylesheets
* gate: inline styles apply at the record's arrival (the walk runs ahead of
* the segments in the same flush), modules and preloads likewise, and a
* segment with none of these never waits on the tier.
*
* The module's exports ARE its dispatch: `prepareTier` stamps the load with
* the module once it has resolved (`tierLoads.assets.r`), and the eager
* client calls `gate` from `#segmentReady` and `apply` from `#flush`'s
* assets walk off that stamp. No `install()`: the tier registers nothing
* and imports nothing of the client — it needs no shared instance, only
* the frame handed to `gate` (its `apply` is the flush a settled sheet
* triggers).
*
* The server announces this tier wherever it emits an assets chunk
* (`sink.needs("assets")` — the shell's pre-flush assets, a style-gated
* fragment's, a late module / preload; frame-sink.ts): `X-Frame-Tiers` /
* `chunk.tiers` on a stream, `_$HY.r["sc:tiers"]` + a `modulepreload` on a
* document — so the load is a warm start; an un-announced record starts it
* from the readiness check or the walk and the segment waits (the same DOM,
* later).
* @experimental
*/
import type { Frame } from "./frame-client.js";

/** A stylesheet entry as the sink writes it: a url, or `{ href, attrs }`. */
export type StylesheetEntry = string | { href: string; attrs?: Record<string, string> };
/** A typed preload entry (`<link rel="preload">`): request-qualifying attributes, optional href. */
export interface PreloadEntry {
href?: string;
attrs: Record<string, string>;
}
/** An inline style entry (`<style data-asset>`). */
export interface InlineStyleEntry {
id: string;
content?: string;
attrs?: Record<string, string>;
}
/** The head-affecting members of a `seg:<k>:assets` record. */
export interface AssetsRecord {
modules?: string[];
styles?: StylesheetEntry[];
inlineStyles?: InlineStyleEntry[];
preloads?: PreloadEntry[];
}

// Minimal, import-free mirror of the client asset registry's conventions
// (client.js acquireAsset): data-asset ids for inline styles, attribute-
// compared lookup instead of selector interpolation, adopt elements already
// in the document. The Solid binding can swap in the ref-counted registry
// later; the gate only needs "are this segment's stylesheets loaded, and
// call me back when they settle".

// Mirrors head.ts without importing it into the standalone frame client.
const PRELOAD_QUALIFIERS = ["as", "crossorigin", "type", "media", "imagesrcset", "imagesizes"];

// Mirrors head.ts's qualifierValue — keep them in step. `as` folds ASCII
// case; an empty source set or size reads as absent (registration never
// emits one); `crossorigin` is three states, not a string range, so `""`, a
// bare attribute and `anonymous` are one request. Frame `attrs` are already
// canonical strings, but the document may carry any spelling.
function qualifierValue(name: string, value: string | null) {
if (value == null) return null;
if (name === "imagesrcset" || name === "imagesizes") return value === "" ? null : value;
if (name === "as") return value.replace(/[A-Z]/g, c => String.fromCharCode(c.charCodeAt(0) + 32));
if (name !== "crossorigin") return value;
return value.length === 15 && value.toLowerCase() === "use-credentials"
? "use-credentials"
: "anonymous";
}

/** Attribute-compared head lookup so href/id values never need escaping. */
function findHeadElement(
selector: string,
attr: string,
value: string | null,
qualifiers?: Record<string, string>
) {
candidate: for (const node of document.head.querySelectorAll(selector)) {
if (node.getAttribute(attr) !== value) continue;
if (!qualifiers) return node;
for (let i = 0; i < PRELOAD_QUALIFIERS.length; i++) {
const name = PRELOAD_QUALIFIERS[i];
if (
qualifierValue(name, node.getAttribute(name)) !==
qualifierValue(name, qualifiers[name] ?? null)
)
continue candidate;
}
return node;
}
return null;
}

/** Ensure one typed preload exists, preserving request-qualifying attributes. */
function ensurePreload(entry: PreloadEntry) {
const attrs = entry.attrs;
const href = entry.href;
if (findHeadElement('link[rel="preload"]', "href", href || null, attrs)) return;
const link = document.createElement("link");
link.rel = "preload";
for (const name in attrs) link.setAttribute(name, attrs[name]);
if (href) link.setAttribute("href", href);
document.head.appendChild(link);
}

/**
* Ensure a stylesheet link exists and report whether it has settled. A link
* this loader created tracks waiters until load/error (error unblocks too —
* same policy as the document runtime's $dfc gate); a link that was
* already in the document counts as settled. `entry` is a url string or an
* attributed record `{ href, attrs }` (fetch-metadata attributes carried by
* useHead stylesheets).
*/
function ensureStylesheet(entry: StylesheetEntry, onSettle: () => void) {
const href = typeof entry === "string" ? entry : entry.href;
let link = findHeadElement('link[rel="stylesheet"]', "href", href) as any;
if (!link) {
link = document.createElement("link");
link.rel = "stylesheet";
if (typeof entry !== "string" && entry.attrs) {
for (const name in entry.attrs) link.setAttribute(name, entry.attrs[name]);
}
link.href = href;
const waiters = new Set<() => void>();
link._$frWaiters = waiters;
const settle = () => {
link._$frWaiters = null;
for (const fn of waiters) fn();
};
link.addEventListener("load", settle);
link.addEventListener("error", settle);
document.head.appendChild(link);
}
const waiters = link._$frWaiters;
if (waiters == null) return true; // settled, or document-owned
waiters.add(onSettle);
return false;
}

/** Ensure a modulepreload link exists for `href` (deduped, adopt existing). */
function ensureModulePreload(href: string) {
if (findHeadElement('link[rel="modulepreload"]', "href", href)) return;
const link = document.createElement("link");
link.rel = "modulepreload";
link.href = href;
document.head.appendChild(link);
}

/** Insert inline-style entries into the head, deduped by data-asset id. */
function applyInlineStyles(inlineStyles: InlineStyleEntry[]) {
for (const entry of inlineStyles) {
if (findHeadElement("style[data-asset]", "data-asset", entry.id)) continue;
const el = document.createElement("style");
el.setAttribute("data-asset", entry.id);
if (entry.attrs) {
for (const name in entry.attrs) el.setAttribute(name, entry.attrs[name]);
}
el.textContent = entry.content || "";
document.head.appendChild(el);
}
}

// The flush a settled stylesheet wakes, one per frame: a pending link holds
// at most one waiter per frame across repeated readiness checks (the
// waiters are a Set — identity dedupes). An empty write at the frame's own
// version is the re-flush (`prepareTier`'s install does the same); a
// disposed frame's `apply` is a no-op.
const flushers = new WeakMap<Frame, () => void>();

/**
* The stylesheet gate for one segment (`#segmentReady`'s style term, once
* the tier is resident): ensure every named sheet is in the head — pending
* links are inserted now, even while another prerequisite is missing, so
* the load overlaps the rest of the stream — and report whether all have
* settled. `frame` is re-flushed when a pending one settles.
*/
export function gate(entries: StylesheetEntry[], frame: Frame): boolean {
let flush = flushers.get(frame);
if (!flush) flushers.set(frame, (flush = () => frame.apply({ version: frame.version!, r: {} })));
let ready = true;
for (const entry of entries) ready = ensureStylesheet(entry, flush) && ready;
return ready;
}

/**
* The assets pass for one record (`#flush`'s walk, once per record identity
* per mount): module preloads, typed preloads, inline styles. Stylesheets
* are the gate's (`gate`) — never applied here.
*/
export function apply(record: AssetsRecord): void {
if (record.modules) for (const href of record.modules) ensureModulePreload(href);
if (record.preloads) for (const entry of record.preloads) ensurePreload(entry);
if (record.inlineStyles) applyInlineStyles(record.inlineStyles);
}
35 changes: 27 additions & 8 deletions packages/web/frames/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ import {
createFrameHost,
FRAME_ID_ATTR,
prepareTier,
tierLoaders
tierLoaders,
type TierModule
} from "./frame-client.js";
import {
COMPONENT_BINDING,
Expand Down Expand Up @@ -72,6 +73,18 @@ import { createLoadingBoundary, sharedConfig } from "solid-js/internal";
// in the lazy codec chunk.
const TRACE_STATE = Symbol.for("solid.container-trace-state");
tierLoaders.trace = () => import("@solidjs/web/frames/trace");
// The assets tier (frames savings pass §3 row C5): the head mirror a
// segment's `seg:<k>:assets` record drives — the stylesheet gate, module
// and typed preloads, inline styles — as the chunk `@solidjs/web/frames/
// assets` (assets-tier.ts), loaded through the tier mechanism: the server
// announces `assets` wherever it emits an assets chunk, and a record met
// while the tier is absent starts the load from the readiness check. A
// segment with stylesheets or inline styles is NOT READY until the tier is
// resident, and one with stylesheets not until they have settled (the
// reveal-readiness term in frame-client.ts's #segmentReady): the server's
// fallback stays on screen, no segment reveals unstyled. The module's
// exports are the dispatch (`gate`, `apply`); no install.
tierLoaders.assets = () => import("@solidjs/web/frames/assets");

// Build-time literal (see diagnostics.ts): dev-only guidance folds out of prod.
const IS_DEV = "_SOLID_DEV_" as unknown as boolean;
Expand Down Expand Up @@ -112,6 +125,8 @@ export {
createFrameElement,
FRAME_APPLIED_EVENT
} from "./frame-client.js";
// The shape `InstallOptions.tiers`' loaders resolve (type-only).
export type { TierModule } from "./frame-client.js";
export {
FRAME_STREAM_HEADER,
FRAME_HAVE_HEADER,
Expand Down Expand Up @@ -1648,13 +1663,15 @@ function adoptBoundary(
export interface InstallOptions {
/**
* Frames-client tiers by name → loader. A tier's module exports
* `install()`, called once the import resolves; every live frame is then
* `install()`, called once the import resolves, and/or the appliers the
* client dispatches to off the resident module; every live frame is then
* flushed so what the tier makes applicable applies (a held occurrence
* mounts). A name with no loader is resident (eager); `trace` has a
* built-in loader (`@solidjs/web/frames/trace`) that an entry here
* replaces. See `installServerComponents`.
* mounts, a style-gated segment requests its sheets). A name with no
* loader is resident (eager); `trace` (`@solidjs/web/frames/trace`) and
* `assets` (`@solidjs/web/frames/assets`) have built-in loaders that an
* entry here replaces. See `installServerComponents`.
*/
tiers?: Record<string, () => Promise<{ install?(): void }>>;
tiers?: Record<string, () => Promise<TierModule>>;
}

/**
Expand All @@ -1679,8 +1696,10 @@ export interface InstallOptions {
* (`_$HY.r["sc:tiers"]`, `X-Frame-Tiers`) and the loads start here from
* the document's record — the `modulepreload` the document may also carry
* made the fetch warm. The built-in table carries `trace` (the container
* tier's client half, `@solidjs/web/frames/trace`); a loader given here
* for a name replaces the built-in one (tests gate a tier's load this way).
* tier's client half, `@solidjs/web/frames/trace`) and `assets` (the head
* mirror and the stylesheet gate, `@solidjs/web/frames/assets`); a loader
* given here for a name replaces the built-in one (tests gate a tier's
* load this way).
* @experimental
*/
export function installServerComponents(host: any = getFrameHost(), options?: InstallOptions) {
Expand Down
Loading