Skip to content
Open
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
31 changes: 29 additions & 2 deletions CLAY-VITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,32 @@ flowchart TD
`_manifest.json` is written β†’ new path activates. Remove the flag β†’ `_manifest.json` is
absent on the next deploy β†’ old path activates. No code changes needed in the site.

### 4d. Module preloading & the on-demand component waterfall

View-mode pages inject **one** `<script type="module">` β€” the bootstrap β€” at the end of
`<body>`. The bootstrap then scans the DOM and `import()`s each present component's
`client.js` on demand. That on-demand model keeps the initial parse tiny, but naively it
creates a **load waterfall**: the browser cannot discover a component's chunk (nav, header,
…) or its dependencies (auth, gtm, shared chunks) until the deferred bootstrap has
downloaded, executed, and called `import()`. For JS-gated UI β€” e.g. the global nav revealing
its account / sign-in links β€” that delay is visible, especially on cold cache or high latency.

Two layers of `<link rel="modulepreload">` hints flatten the waterfall. They are opt-in on
the amphora-html side (`configure({ modulepreload: true })`) and emitted into `<head>` so the
browser starts fetching during HTML parse instead of after the bootstrap runs:

| Layer | Source | What it preloads |
|---|---|---|
| Startup graph | `getViteModulePreloads()` (via `resolveModuleScripts`) | the bootstrap **plus its static imports** β€” `_globals-init`, `_env-init`, and the shared chunks the bootstrap pulls in synchronously |
| On-page components | `getComponentPreloads(componentNames, assetPath)` | each rendered component's `client.js` chunk **plus its static import chunks**, de-duped |

`getComponentPreloads` depends on the manifest keying **dynamic** entries: `buildManifest`
records every component/layout `client.js` chunk under `components/<name>/client` (and
`layouts/<name>/client`), not just the bootstrap and kiln entries. The **server** (amphora
`resolve-media.js`) owns the policy: it passes the page's component list
(`locals._components`) to `getComponentPreloads` and merges the result into
`media.modulePreloads`, de-duping against the startup-graph preloads.

## 5. Feature-by-Feature Comparison

### JavaScript Bundling
Expand Down Expand Up @@ -444,6 +470,7 @@ absent on the next deploy β†’ old path activates. No code changes needed in the
| **How scripts are resolved** | `getDependencies()` reads `_registry.json` | `resolveModuleScripts()` reads `_manifest.json` |
| **Edit mode scripts** | All `_deps-*.js` + `_models-*.js` + `_kiln-*.js` + templates | Single `_kiln-edit-init` bundle + templates |
| **View mode scripts** | Numeric IDs β†’ individual dep files | `vite-bootstrap` + `_globals-init` + shared chunks (typically 3–5 files) |
| **Preload hints** | None | `<link rel="modulepreload">` for the bootstrap startup graph (`getViteModulePreloads`) + on-page component chunks (`getComponentPreloads`) β€” see Β§4d |
| **Global scripts** | Individual files per registry entry (70–100 requests) | All `global/js/*.js` in one `_globals-init.js` (1 request) |

## 6. Configuration
Expand Down Expand Up @@ -660,8 +687,8 @@ RUN if [ "$CLAYCLI_VITE_ENABLED" = "true" ]; then \
| Font processing | [`lib/cmd/vite/fonts.js`](./lib/cmd/vite/fonts.js) | Font copy + CSS generation; `buildFonts`, `FONTS_SRC_GLOB` |
| Media copy | [`lib/cmd/vite/media.js`](./lib/cmd/vite/media.js) | Copies media files to `public/media/`; `copyMedia` |
| Vendor copy | [`lib/cmd/vite/vendor.js`](./lib/cmd/vite/vendor.js) | Copies `clay-kiln` dist files to `public/js/`; `copyVendor` |
| Manifest writer | [`lib/cmd/vite/scripts.js`](./lib/cmd/vite/scripts.js) | `buildManifest`, `writeManifest` β€” writes `_manifest.json` |
| Script dependency resolver | [`lib/cmd/vite/index.js`](./lib/cmd/vite/index.js) | `resolveModuleScripts`, `hasManifest` β€” runtime helpers for `resolve-media.js` |
| Manifest writer | [`lib/cmd/vite/scripts.js`](./lib/cmd/vite/scripts.js) | `buildManifest` (keys static **and** dynamic component entries), `writeManifest` β€” writes `_manifest.json` |
| Script / preload resolver | [`lib/cmd/vite/index.js`](./lib/cmd/vite/index.js) | `resolveModuleScripts`, `getComponentPreloads`, `hasManifest` β€” runtime helpers for `resolve-media.js` (see Β§4d) |

### Vite plugins

Expand Down
107 changes: 96 additions & 11 deletions lib/cmd/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,35 @@ function readManifest() {

// ── Script URL helpers ───────────────────────────────────────────────────────

/**
* Return a manifest entry's file URL plus its static import chunk URLs, each
* rebased onto the site's asset host/prefix. Returns [] when the entry is
* missing (component has no client.js, or the build didn't emit it) or has no
* file. This is the shared primitive behind the module/preload resolvers.
*
* @param {object|undefined} entry - a { file, imports } manifest entry
* @param {function} rebase - maps a `/js/...` URL onto the asset host
* @returns {string[]}
*/
function entryUrls(entry, rebase) {
if (!entry || !entry.file) return [];

return [entry.file].concat(entry.imports || []).map(rebase);
}

/**
* Build the rebasing function that maps a manifest's `/js/...` URLs onto the
* site's asset host/prefix (e.g. 'https://cdn.example.com/js/...').
*
* @param {string} [assetPath]
* @returns {function}
*/
function makeRebase(assetPath) {
const base = (assetPath || '') + '/js';

return p => p.replace(/^\/js/, base);
}

/**
* Return the bootstrap script URL for view mode.
* One <script type="module"> tag is all the browser needs β€”
Expand All @@ -62,13 +91,70 @@ function getViteViewScripts(assetPath) {
}

/**
* Return the same URL list for <link rel="modulepreload"> hints.
* View-mode <link rel="modulepreload"> URLs: the bootstrap entry PLUS its
* static import chunks (globals-init, env-init, and the shared chunks the
* bootstrap pulls in synchronously).
*
* Preloading the imports β€” not just the bootstrap file β€” lets the browser
* fetch the whole synchronous startup graph in parallel during HTML parse
* instead of discovering each chunk only after the bootstrap module itself
* downloads (one waterfall hop per level). Per-component client chunks are
* dynamic imports and are NOT included here β€” use getComponentPreloads().
*
* @param {string} [assetPath]
* @returns {string[]}
*/
function getViteModulePreloads(assetPath) {
return getViteViewScripts(assetPath);
if (!hasManifest()) return [];

const manifest = readManifest();

return entryUrls(manifest && manifest[VITE_BOOTSTRAP_KEY], makeRebase(assetPath));
}

/**
* Return de-duplicated <link rel="modulepreload"> URLs for a set of component
* names β€” each component's client chunk plus its static import chunks β€” so the
* server can hint the components that are actually on the page.
*
* Why this exists: the view-mode bootstrap mounts components by scanning the
* DOM and calling import('components/<name>/client.js') at runtime. Those
* dynamic chunks are not referenced by any tag in the server-rendered HTML, so
* the browser cannot begin fetching a component's client (or its deps β€” auth,
* gtm, shared chunks) until the bootstrap executes. That is a load waterfall
* that visibly delays JS-gated UI such as the global nav. Preloading the
* on-page component chunks flattens the waterfall into parallel fetches that
* start during HTML parse.
*
* Relies on the enriched manifest that keys dynamic component entries
* (buildManifest emits `components/<name>/client`). Names with no manifest
* entry (no client.js, or not built) are skipped. Callers should merge the
* result into media.modulePreloads, de-duping against the bootstrap-graph
* preloads that getViteModulePreloads already provides.
*
* @param {string[]} componentNames - component names rendered on the page
* @param {string} [assetPath] - site asset host/prefix
* @returns {string[]}
*/
function getComponentPreloads(componentNames, assetPath) {
if (!hasManifest() || !Array.isArray(componentNames) || !componentNames.length) {
return [];
}

const manifest = readManifest();

if (!manifest) return [];

const rebase = makeRebase(assetPath);
const urls = new Set();

for (const name of componentNames) {
for (const url of entryUrls(manifest[`components/${name}/client`], rebase)) {
urls.add(url);
}
}

return [...urls];
}

/**
Expand All @@ -81,21 +167,19 @@ function getEditScripts(assetPath) {
if (!hasManifest()) return [];

const manifest = readManifest();
const entry = manifest && manifest[KILN_EDIT_ENTRY_KEY];

if (!entry || !entry.file) return [];

const base = (assetPath || '') + '/js';
const rebase = p => p.replace(/^\/js/, base);

return [entry.file].concat(entry.imports || []).map(rebase);
return entryUrls(manifest && manifest[KILN_EDIT_ENTRY_KEY], makeRebase(assetPath));
}

/**
* Populate media.moduleScripts and media.modulePreloads for amphora-html.
*
* In view mode: one bootstrap URL.
* In edit mode: bootstrap + kiln edit bundle.
* moduleScripts (the actual <script type="module"> tags):
* view mode β†’ one bootstrap URL; edit mode β†’ bootstrap + kiln edit bundle.
* modulePreloads (<link rel="modulepreload"> hints):
* always the bootstrap's synchronous startup graph (bootstrap file + its
* static import chunks). Per-component chunks are added separately by the
* server via getComponentPreloads() β€” see that function for the rationale.
*
* @param {object} media
* @param {string} assetPath
Expand Down Expand Up @@ -159,6 +243,7 @@ module.exports = {
getViteConfig,
hasManifest,
resolveModuleScripts,
getComponentPreloads,
getEditScripts,
getTemplatePaths,
getDependenciesNext,
Expand Down
98 changes: 97 additions & 1 deletion lib/cmd/vite/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ describe('index β€” view mode scripts', () => {
expect(media.moduleScripts[0]).toBe('https://cdn.example.com/js/bootstrap-abc123.js');
});

it('sets modulePreloads to the same URL as moduleScripts', () => {
it('sets modulePreloads to the same URL as moduleScripts when the bootstrap has no imports', () => {
const { mod, cleanup } = withManifest({
[BOOTSTRAP_KEY]: { file: '/js/bootstrap-abc123.js', imports: [] },
});
Expand All @@ -159,6 +159,102 @@ describe('index β€” view mode scripts', () => {

expect(media.modulePreloads).toEqual(media.moduleScripts);
});

it('preloads the bootstrap PLUS its static import chunks (startup graph)', () => {
const { mod, cleanup } = withManifest({
[BOOTSTRAP_KEY]: {
file: '/js/bootstrap-abc123.js',
imports: ['/js/chunks/globals-init-1.js', '/js/chunks/utils-2.js'],
},
});
const media = {};

mod.resolveModuleScripts(media, '', { edit: false });
cleanup();

// The <script type=module> is still only the bootstrap...
expect(media.moduleScripts).toEqual(['/js/bootstrap-abc123.js']);
// ...but the preloads warm the whole synchronous startup graph so the
// browser fetches it in parallel with HTML parse instead of in a waterfall.
expect(media.modulePreloads).toEqual([
'/js/bootstrap-abc123.js',
'/js/chunks/globals-init-1.js',
'/js/chunks/utils-2.js',
]);
});
});

// ── component preloads ──────────────────────────────────────────────────────────

describe('index β€” getComponentPreloads', () => {
const MANIFEST = {
[BOOTSTRAP_KEY]: { file: '/js/bootstrap-abc.js', imports: [] },
'components/global-nav/client': {
file: '/js/chunks/global-nav-client-1.js',
imports: ['/js/chunks/auth-2.js', '/js/chunks/utils-3.js'],
},
'components/header-navigation/client': {
file: '/js/chunks/header-navigation-client-4.js',
// shares utils with global-nav β€” must be de-duped across components
imports: ['/js/chunks/utils-3.js'],
},
};

it('returns each component chunk plus its imports, de-duped across components', () => {
const { mod, cleanup } = withManifest(MANIFEST);
const preloads = mod.getComponentPreloads(['global-nav', 'header-navigation'], '');

cleanup();

expect(preloads).toEqual([
'/js/chunks/global-nav-client-1.js',
'/js/chunks/auth-2.js',
'/js/chunks/utils-3.js',
'/js/chunks/header-navigation-client-4.js',
]);
});

it('prefixes every URL with assetPath', () => {
const { mod, cleanup } = withManifest(MANIFEST);
const preloads = mod.getComponentPreloads(['global-nav'], 'https://cdn.example.com');

cleanup();

expect(preloads).toEqual([
'https://cdn.example.com/js/chunks/global-nav-client-1.js',
'https://cdn.example.com/js/chunks/auth-2.js',
'https://cdn.example.com/js/chunks/utils-3.js',
]);
});

it('skips components with no manifest entry (no client.js or not built)', () => {
const { mod, cleanup } = withManifest(MANIFEST);
const preloads = mod.getComponentPreloads(['global-nav', 'does-not-exist'], '');

cleanup();

expect(preloads).toEqual([
'/js/chunks/global-nav-client-1.js',
'/js/chunks/auth-2.js',
'/js/chunks/utils-3.js',
]);
});

it('returns an empty array when there is no manifest', () => {
const { mod, cleanup } = withNoManifest();
const preloads = mod.getComponentPreloads(['global-nav'], '');

cleanup();
expect(preloads).toEqual([]);
});

it('returns an empty array for empty or non-array input', () => {
const { mod, cleanup } = withManifest(MANIFEST);

expect(mod.getComponentPreloads([], '')).toEqual([]);
expect(mod.getComponentPreloads(undefined, '')).toEqual([]);
cleanup();
});
});

// ── edit mode scripts ─────────────────────────────────────────────────────────
Expand Down
25 changes: 24 additions & 1 deletion lib/cmd/vite/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,19 @@ function buildPlugins(extraPlugins = [], browserStubs = {}, opts = {}) {

// ── Manifest ────────────────────────────────────────────────────────────────

/**
* True for chunks that should be keyed in the manifest: static entries
* (bootstrap, kiln-edit) and dynamic entries (the component/layout client.js
* chunks loaded via import()). Everything else (shared/vendor chunks) is
* reachable through an entry's imports[] and is not keyed on its own.
*
* @param {object} chunk - a Rollup OutputChunk
* @returns {boolean}
*/
function isManifestChunk(chunk) {
return chunk.type === 'chunk' && (chunk.isEntry || chunk.isDynamicEntry);
}

/**
* Build _manifest.json from one or two Vite RollupOutput results.
*
Expand All @@ -354,6 +367,14 @@ function buildPlugins(extraPlugins = [], browserStubs = {}, opts = {}) {
* must produce a compatible manifest so the runtime asset injector works
* without knowing which bundler was used.
*
* Both static entries (bootstrap, kiln-edit) and dynamic entries (the
* per-component/layout client.js chunks loaded via import()) are keyed. The
* dynamic entries are what let the server preload the components on a page
* (see getComponentPreloads in index.js): without them the manifest exposes
* only the bootstrap, so the browser cannot discover a component's chunk until
* the bootstrap import()s it at runtime β€” a request waterfall that delays
* JS-gated UI such as the global nav.
*
* @param {Object|null} viewOutput
* @param {Object|null} kilnOutput
* @param {string} publicBase
Expand All @@ -364,7 +385,9 @@ function buildManifest(viewOutput, kilnOutput = null, publicBase = '/js') {

for (const output of [viewOutput, kilnOutput]) {
for (const chunk of output ? output.output : []) {
if (chunk.type !== 'chunk' || !chunk.isEntry) continue;
// Static entries (bootstrap, kiln-edit) AND dynamic entries (component/
// layout client.js loaded via import()) are keyed β€” see isManifestChunk.
if (!isManifestChunk(chunk)) continue;

const facadeId = chunk.facadeModuleId;

Expand Down
Loading
Loading