@webjsdev/server 0.8.75 → 0.8.77
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 +1 -1
- package/src/component-elision.js +151 -0
- package/src/component-scanner.js +26 -6
- package/src/dev/handler.js +15 -3
- package/src/dev/serve.js +32 -4
- package/src/ssr/preloads.js +7 -2
- package/src/ssr/render.js +21 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.77",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/component-elision.js
CHANGED
|
@@ -1519,3 +1519,154 @@ export function elideImportsFromSource(source, importerAbs, moduleGraph, elidabl
|
|
|
1519
1519
|
out += source.slice(last);
|
|
1520
1520
|
return out;
|
|
1521
1521
|
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Defer side-effect imports of `static lazy` components (#1524). A lazy
|
|
1525
|
+
* component that a shipping module imports for registration used to load
|
|
1526
|
+
* eagerly as part of that module's static graph, which defeated `static lazy`
|
|
1527
|
+
* whenever the class had to be imported at all (and SSR needs the import: the
|
|
1528
|
+
* server only knows the tag is lazy once the class registered). The SERVER
|
|
1529
|
+
* keeps the real import; this rewrites only the BROWSER copy, replacing the
|
|
1530
|
+
* import line in place (same line, so positions stay stable) with a
|
|
1531
|
+
* registration on the lazy loader:
|
|
1532
|
+
*
|
|
1533
|
+
* import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy({"x-tag":"/url"}));
|
|
1534
|
+
*
|
|
1535
|
+
* The loader then imports the module the first time an element with one of
|
|
1536
|
+
* its tags is in (or near) the viewport: a hidden tab panel when it first
|
|
1537
|
+
* shows, a closed dialog's content when it opens, a below-the-fold element
|
|
1538
|
+
* when it scrolls near. Its MutationObserver also catches a tag rendered
|
|
1539
|
+
* later on the client, so an imported-but-not-yet-rendered component still
|
|
1540
|
+
* registers when it appears.
|
|
1541
|
+
*
|
|
1542
|
+
* Same contract as {@link elideImportsFromSource}: only a side-effect import
|
|
1543
|
+
* is rewritten (a binding import needs its value now, so it stays eager), the
|
|
1544
|
+
* match runs over a redacted copy so an import-looking line inside a string,
|
|
1545
|
+
* template, or comment is never touched, and an importer with no lazy
|
|
1546
|
+
* dependency in the graph returns untouched with no regex work. Run it AFTER
|
|
1547
|
+
* the elision strip: an elided import is already a comment by then.
|
|
1548
|
+
*
|
|
1549
|
+
* @param {string} source module source (already type-stripped if TS)
|
|
1550
|
+
* @param {string} importerAbs absolute path of the importing module
|
|
1551
|
+
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
1552
|
+
* @param {Map<string, string[]> | undefined} lazyFiles lazy component file -> the tags it registers
|
|
1553
|
+
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
1554
|
+
* @param {string} appDir
|
|
1555
|
+
* @param {(abs: string) => string} urlFor the browser URL the loader imports (base path + content hash applied)
|
|
1556
|
+
* @returns {string}
|
|
1557
|
+
*/
|
|
1558
|
+
export function deferLazyImportsFromSource(source, importerAbs, moduleGraph, lazyFiles, resolveImport, appDir, urlFor) {
|
|
1559
|
+
if (!lazyFiles || lazyFiles.size === 0) return source;
|
|
1560
|
+
const deps = moduleGraph && moduleGraph.get(importerAbs);
|
|
1561
|
+
if (!deps) return source;
|
|
1562
|
+
let hasLazyDep = false;
|
|
1563
|
+
for (const d of deps) {
|
|
1564
|
+
if (lazyFiles.has(d)) { hasLazyDep = true; break; }
|
|
1565
|
+
}
|
|
1566
|
+
if (!hasLazyDep) return source;
|
|
1567
|
+
|
|
1568
|
+
const redacted = redactStringsAndTemplates(source);
|
|
1569
|
+
let out = '';
|
|
1570
|
+
let last = 0;
|
|
1571
|
+
for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
|
|
1572
|
+
const start = /** @type {number} */ (m.index);
|
|
1573
|
+
const end = start + m[0].length;
|
|
1574
|
+
const resolved = resolveImport(m[3], importerAbs, appDir);
|
|
1575
|
+
const tags = resolved ? lazyFiles.get(resolved) : undefined;
|
|
1576
|
+
out += source.slice(last, start);
|
|
1577
|
+
if (tags && tags.length) {
|
|
1578
|
+
const url = urlFor(/** @type {string} */ (resolved));
|
|
1579
|
+
const entries = Object.fromEntries(tags.map((t) => [t, url]));
|
|
1580
|
+
out += `${m[1]}import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy(${JSON.stringify(entries)}));`;
|
|
1581
|
+
} else {
|
|
1582
|
+
out += source.slice(start, end);
|
|
1583
|
+
}
|
|
1584
|
+
last = end;
|
|
1585
|
+
}
|
|
1586
|
+
out += source.slice(last);
|
|
1587
|
+
return out;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* The lazy component files of an app (#1524): every scanned component file
|
|
1592
|
+
* that declares `static lazy = true`, mapped to the tags it registers. An
|
|
1593
|
+
* elided (display-only) file is left out: its import is stripped outright,
|
|
1594
|
+
* which beats deferring it.
|
|
1595
|
+
*
|
|
1596
|
+
* @param {Array<{ tag: string, file: string, lazy?: boolean }>} components
|
|
1597
|
+
* @param {Set<string>} [elidable]
|
|
1598
|
+
* @returns {Map<string, string[]>}
|
|
1599
|
+
*/
|
|
1600
|
+
export function lazyComponentFiles(components, elidable) {
|
|
1601
|
+
/** @type {Map<string, string[]>} */
|
|
1602
|
+
const out = new Map();
|
|
1603
|
+
for (const c of components) {
|
|
1604
|
+
if (!c.lazy || (elidable && elidable.has(c.file))) continue;
|
|
1605
|
+
const tags = out.get(c.file);
|
|
1606
|
+
if (tags) { if (!tags.includes(c.tag)) tags.push(c.tag); } else out.set(c.file, [c.tag]);
|
|
1607
|
+
}
|
|
1608
|
+
return out;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** `import <bindings> from 'x'` (the bindings part is captured). */
|
|
1612
|
+
const BINDING_IMPORT_RE = /\bimport\s+(type\s+)?([\w$*{}\s,]+?)\s+from\s+(['"])([^'"]+)\3/g;
|
|
1613
|
+
/** `export { a } from 'x'` / `export * from 'x'` (a re-export needs the module too). */
|
|
1614
|
+
const REEXPORT_RE = /\bexport\s+(type\s+)?(?:\*|\{[^}]*\}|\*\s+as\s+[\w$]+)\s+from\s+(['"])([^'"]+)\2/g;
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Is this import clause types only (`import type { A }`, or every named
|
|
1618
|
+
* specifier marked `type`)? Those are erased before the browser sees them.
|
|
1619
|
+
*
|
|
1620
|
+
* @param {string | undefined} typeKw
|
|
1621
|
+
* @param {string} clause
|
|
1622
|
+
*/
|
|
1623
|
+
function typeOnlyClause(typeKw, clause) {
|
|
1624
|
+
if (typeKw) return true;
|
|
1625
|
+
const m = clause.trim().match(/^\{([\s\S]*)\}$/);
|
|
1626
|
+
if (!m) return false;
|
|
1627
|
+
const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
1628
|
+
return names.length > 0 && names.every((n) => /^type\s/.test(n));
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* The lazy component files some module imports WITH bindings (#1524). A
|
|
1633
|
+
* binding import loads the module eagerly whatever the class says, because the
|
|
1634
|
+
* importer needs the value when it runs. Such a file is not lazy in practice,
|
|
1635
|
+
* so the caller drops it from the lazy set: otherwise the preload walks would
|
|
1636
|
+
* skip a module the browser still fetches up front, which only makes it slower.
|
|
1637
|
+
*
|
|
1638
|
+
* Only importers whose graph edges reach a lazy file are read. A server file is
|
|
1639
|
+
* never served, so its imports do not count.
|
|
1640
|
+
*
|
|
1641
|
+
* @param {Map<string, string[]>} lazyFiles
|
|
1642
|
+
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
1643
|
+
* @param {(file: string) => Promise<string>} readFileFn
|
|
1644
|
+
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
1645
|
+
* @param {string} appDir
|
|
1646
|
+
* @returns {Promise<Set<string>>}
|
|
1647
|
+
*/
|
|
1648
|
+
export async function bindingImportedLazyFiles(lazyFiles, moduleGraph, readFileFn, resolveImport, appDir) {
|
|
1649
|
+
/** @type {Set<string>} */
|
|
1650
|
+
const out = new Set();
|
|
1651
|
+
if (!lazyFiles.size || !moduleGraph) return out;
|
|
1652
|
+
for (const [importer, deps] of moduleGraph) {
|
|
1653
|
+
if (SERVER_FILE_RE.test(importer)) continue;
|
|
1654
|
+
let reaches = false;
|
|
1655
|
+
for (const d of deps) if (lazyFiles.has(d) && !out.has(d)) { reaches = true; break; }
|
|
1656
|
+
if (!reaches) continue;
|
|
1657
|
+
let src;
|
|
1658
|
+
try { src = await readFileFn(importer); } catch { continue; }
|
|
1659
|
+
const code = redactStringsAndTemplates(maskComments(src));
|
|
1660
|
+
for (const m of code.matchAll(BINDING_IMPORT_RE)) {
|
|
1661
|
+
if (typeOnlyClause(m[1], m[2])) continue;
|
|
1662
|
+
const abs = resolveImport(m[4], importer, appDir);
|
|
1663
|
+
if (abs && lazyFiles.has(abs)) out.add(abs);
|
|
1664
|
+
}
|
|
1665
|
+
for (const m of code.matchAll(REEXPORT_RE)) {
|
|
1666
|
+
if (m[1]) continue;
|
|
1667
|
+
const abs = resolveImport(m[3], importer, appDir);
|
|
1668
|
+
if (abs && lazyFiles.has(abs)) out.add(abs);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return out;
|
|
1672
|
+
}
|
package/src/component-scanner.js
CHANGED
|
@@ -28,7 +28,7 @@ import { redactToPlaceholders } from './js-scan.js';
|
|
|
28
28
|
* after a single `stat`). Makes the component scan incremental for large apps.
|
|
29
29
|
* Keyed by mtime AND size (a same-tick length-changing edit is caught even on
|
|
30
30
|
* coarse-mtime filesystems).
|
|
31
|
-
* @type {Map<string, { mtimeMs: number, size: number, comps: Array<{ tag: string, className: string }
|
|
31
|
+
* @type {Map<string, { mtimeMs: number, size: number, comps: Array<{ tag: string, className: string }>, lazy: boolean }>}
|
|
32
32
|
*/
|
|
33
33
|
const SCAN_CACHE = new Map();
|
|
34
34
|
|
|
@@ -75,16 +75,34 @@ export function extractComponents(src) {
|
|
|
75
75
|
return results;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Does this component module declare `static lazy = true` (#1524)? Matched on
|
|
80
|
+
* the comment- and string-redacted source, so the phrase inside a comment or a
|
|
81
|
+
* string literal does not count. The answer is per FILE: a lazy module is
|
|
82
|
+
* loaded whole, so every tag it registers is deferred together (one component
|
|
83
|
+
* per file is the convention anyway).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} src
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function declaresLazy(src) {
|
|
89
|
+
if (!src.includes('lazy')) return false;
|
|
90
|
+
const { redacted } = redactToPlaceholders(src);
|
|
91
|
+
return /\bstatic\s+(?:override\s+|readonly\s+)*lazy\s*(?::\s*boolean\s*)?=\s*true\b/.test(redacted);
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
/**
|
|
79
95
|
* Walk an app directory, return every discovered component with its
|
|
80
96
|
* browser-visible URL (rooted at `/`, matching how the dev server
|
|
81
97
|
* serves module files).
|
|
82
98
|
*
|
|
83
99
|
* @param {string} appDir
|
|
84
|
-
*
|
|
100
|
+
* `lazy` is true when the file declares `static lazy = true` (#1524).
|
|
101
|
+
*
|
|
102
|
+
* @returns {Promise<Array<{ tag: string, className: string, moduleUrl: string, file: string, lazy: boolean }>>}
|
|
85
103
|
*/
|
|
86
104
|
export async function scanComponents(appDir) {
|
|
87
|
-
/** @type {Array<{ tag: string, className: string, moduleUrl: string, file: string }>} */
|
|
105
|
+
/** @type {Array<{ tag: string, className: string, moduleUrl: string, file: string, lazy: boolean }>} */
|
|
88
106
|
const components = [];
|
|
89
107
|
/** @type {Set<string>} live component files this scan, for cache eviction */
|
|
90
108
|
const seen = new Set();
|
|
@@ -97,20 +115,22 @@ export async function scanComponents(appDir) {
|
|
|
97
115
|
let mtimeMs, size;
|
|
98
116
|
try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; } catch { continue; }
|
|
99
117
|
seen.add(file); // mark live (hit and miss) for cache eviction
|
|
100
|
-
let comps;
|
|
118
|
+
let comps, lazy;
|
|
101
119
|
const cached = SCAN_CACHE.get(file);
|
|
102
120
|
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
103
121
|
comps = cached.comps;
|
|
122
|
+
lazy = cached.lazy;
|
|
104
123
|
} else {
|
|
105
124
|
let src;
|
|
106
125
|
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
107
126
|
comps = extractComponents(src);
|
|
108
|
-
|
|
127
|
+
lazy = comps.length > 0 && declaresLazy(src);
|
|
128
|
+
SCAN_CACHE.set(file, { mtimeMs, size, comps, lazy });
|
|
109
129
|
}
|
|
110
130
|
if (!comps.length) continue;
|
|
111
131
|
const moduleUrl = toUrlPath(file, appDir);
|
|
112
132
|
for (const c of comps) {
|
|
113
|
-
components.push({ ...c, moduleUrl, file });
|
|
133
|
+
components.push({ ...c, moduleUrl, file, lazy });
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
// Evict scan-cache entries for files no longer walked (renamed/deleted),
|
package/src/dev/handler.js
CHANGED
|
@@ -31,9 +31,9 @@ import { mintNonce, buildCspHeader, cspHeaderName } from '../csp.js';
|
|
|
31
31
|
import { propagateTrustedRemoteIp } from '../rate-limit.js';
|
|
32
32
|
import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from '../vendor.js';
|
|
33
33
|
import { browserEntryFiles } from '../browser-entries.js';
|
|
34
|
-
import { buildModuleGraph, seenFilesFor, appImportsMap, reachableFromEntries } from '../module-graph.js';
|
|
34
|
+
import { buildModuleGraph, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport } from '../module-graph.js';
|
|
35
35
|
import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js';
|
|
36
|
-
import { analyzeElision } from '../component-elision.js';
|
|
36
|
+
import { analyzeElision, lazyComponentFiles, bindingImportedLazyFiles } from '../component-elision.js';
|
|
37
37
|
|
|
38
38
|
import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from '../importmap.js';
|
|
39
39
|
import { stripBasePath, withBasePath } from '../base-path.js';
|
|
@@ -442,6 +442,10 @@ export async function createRequestHandler(opts) {
|
|
|
442
442
|
elidableComponents: new Set(),
|
|
443
443
|
inertRouteModules: new Set(),
|
|
444
444
|
importOnlyRouteModules: new Map(),
|
|
445
|
+
// `static lazy` component files -> their tags (#1524): a side-effect import
|
|
446
|
+
// of one is served as a lazy-loader registration, and the preload walks
|
|
447
|
+
// skip it. Independent of the elision switch.
|
|
448
|
+
lazyComponentFiles: new Map(),
|
|
445
449
|
// The browser-bound ENTRY files (pages / layouts / boundaries / components),
|
|
446
450
|
// kept alongside the expanded gate set because the vendor scan roots at the
|
|
447
451
|
// entries rather than at the closure.
|
|
@@ -609,6 +613,11 @@ export async function createRequestHandler(opts) {
|
|
|
609
613
|
state.elidableComponents = r.elidableComponents;
|
|
610
614
|
state.inertRouteModules = r.inertRouteModules;
|
|
611
615
|
state.importOnlyRouteModules = r.importOnlyRouteModules;
|
|
616
|
+
state.lazyComponentFiles = lazyComponentFiles(components, state.elidableComponents);
|
|
617
|
+
// A lazy component some module imports WITH bindings loads eagerly
|
|
618
|
+
// anyway, so it keeps its preload hint and its imports as written.
|
|
619
|
+
for (const f of await bindingImportedLazyFiles(state.lazyComponentFiles, state.moduleGraph,
|
|
620
|
+
(f) => readFile(f, 'utf8'), resolveImport, appDir)) state.lazyComponentFiles.delete(f);
|
|
612
621
|
// Dev live-reload classification (#1398): derive the three sets the
|
|
613
622
|
// watch-event classifier reads. `browserBoundFiles` above is the
|
|
614
623
|
// AUTHORIZATION gate (it walks from every entry, elided ones
|
|
@@ -647,7 +656,10 @@ export async function createRequestHandler(opts) {
|
|
|
647
656
|
// component imports), so a flip in / out of that class must bust
|
|
648
657
|
// the importer's `?v` too.
|
|
649
658
|
...state.importOnlyRouteModules.keys(),
|
|
650
|
-
|
|
659
|
+
// A file flipping to or from `static lazy` changes how every
|
|
660
|
+
// importer serves its import of it (#1524).
|
|
661
|
+
...[...state.lazyComponentFiles.keys()].map((f) => `lazy:${f}`),
|
|
662
|
+
].map((p) => (p.startsWith('lazy:') ? `lazy:${rel(p.slice(5))}` : rel(p))).sort();
|
|
651
663
|
setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
|
|
652
664
|
}
|
|
653
665
|
// App-source fingerprint for the HTML cache key (#318): a
|
package/src/dev/serve.js
CHANGED
|
@@ -27,11 +27,12 @@ import { maybeRegenerate } from '../dev-regenerate.js';
|
|
|
27
27
|
import { stripTypeScript } from '../ts-strip.js';
|
|
28
28
|
import { serveDownloadedBundle } from '../vendor.js';
|
|
29
29
|
import { resolveImport } from '../module-graph.js';
|
|
30
|
-
import { elideImportsFromSource } from '../component-elision.js';
|
|
30
|
+
import { elideImportsFromSource, deferLazyImportsFromSource } from '../component-elision.js';
|
|
31
31
|
import { annotateSourceLocations, isSourceLocationCandidate, sourceLocationFile } from '../dev-source-locations.js';
|
|
32
32
|
import { basePath } from '../importmap.js';
|
|
33
33
|
import { withBasePath } from '../base-path.js';
|
|
34
|
-
import { versionModuleImports } from '../asset-hash.js';
|
|
34
|
+
import { versionModuleImports, withAssetHash } from '../asset-hash.js';
|
|
35
|
+
import { toUrlPath } from '../ssr/preloads.js';
|
|
35
36
|
import { BUFFERED_MARKER } from '../conditional-get.js';
|
|
36
37
|
import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
|
|
37
38
|
|
|
@@ -322,6 +323,10 @@ export async function handleCore(req, ctx) {
|
|
|
322
323
|
const elideOpts = {
|
|
323
324
|
moduleGraph: state.moduleGraph,
|
|
324
325
|
elidableComponents: state.elidableComponents,
|
|
326
|
+
// A browser test imports the component it tests and expects it defined
|
|
327
|
+
// when the import resolves, so the test handler serves every import as
|
|
328
|
+
// written (#1524).
|
|
329
|
+
lazyComponentFiles: state.testMode ? undefined : state.lazyComponentFiles,
|
|
325
330
|
appDir,
|
|
326
331
|
// Dev source locations (#1499): annotate the served module's `html`
|
|
327
332
|
// templates exactly as the SSR load hook annotates the server's copy.
|
|
@@ -416,6 +421,7 @@ export async function handleCore(req, ctx) {
|
|
|
416
421
|
dev, appDir, moduleGraph: state.moduleGraph,
|
|
417
422
|
serverFiles: state.actionIndex.fileToHash,
|
|
418
423
|
elidableComponents: state.elidableComponents,
|
|
424
|
+
lazyComponentFiles: state.lazyComponentFiles,
|
|
419
425
|
inertRouteModules: state.inertRouteModules,
|
|
420
426
|
importOnlyRouteModules: state.importOnlyRouteModules,
|
|
421
427
|
notFoundFile: state.routeTable.notFound,
|
|
@@ -686,7 +692,7 @@ export async function fileResponse(abs, opts) {
|
|
|
686
692
|
*
|
|
687
693
|
* @param {string} abs
|
|
688
694
|
* @param {boolean} dev
|
|
689
|
-
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
|
|
695
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, lazyComponentFiles?: Map<string, string[]>, appDir: string }} elideOpts
|
|
690
696
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
691
697
|
* serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
|
|
692
698
|
*/
|
|
@@ -697,6 +703,7 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
697
703
|
let code = elideImportsFromSource(
|
|
698
704
|
annotateServedModule(source, abs, elideOpts), abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
699
705
|
);
|
|
706
|
+
code = deferLazyImports(code, abs, elideOpts);
|
|
700
707
|
// Version same-origin relative import specifiers so the URL the browser
|
|
701
708
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
702
709
|
// A no-op in dev (fingerprinting disabled).
|
|
@@ -711,6 +718,26 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
711
718
|
return new Response(code, { status: 200, headers });
|
|
712
719
|
}
|
|
713
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Serve a side-effect import of a `static lazy` component as a lazy-loader
|
|
723
|
+
* registration (#1524). The URL the loader imports is the one the boot script
|
|
724
|
+
* would use: base-path-prefixed, then content-hashed (`?v=`, a no-op in dev),
|
|
725
|
+
* the same composition as `wrapHead`'s lazy map in `ssr/head.js`. It is
|
|
726
|
+
* computed here because `versionModuleImports` (which runs next) deliberately
|
|
727
|
+
* leaves string literals alone.
|
|
728
|
+
*
|
|
729
|
+
* @param {string} code
|
|
730
|
+
* @param {string} abs
|
|
731
|
+
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} opts
|
|
732
|
+
* @returns {string}
|
|
733
|
+
*/
|
|
734
|
+
function deferLazyImports(code, abs, opts) {
|
|
735
|
+
if (!opts.lazyComponentFiles || opts.lazyComponentFiles.size === 0) return code;
|
|
736
|
+
const bp = basePath();
|
|
737
|
+
const urlFor = (file) => withAssetHash(withBasePath(toUrlPath(file, opts.appDir), bp), bp);
|
|
738
|
+
return deferLazyImportsFromSource(code, abs, opts.moduleGraph, opts.lazyComponentFiles, resolveImport, opts.appDir, urlFor);
|
|
739
|
+
}
|
|
740
|
+
|
|
714
741
|
/**
|
|
715
742
|
* Dev source locations (#1499): stamp `data-webjs-src` on the element opening
|
|
716
743
|
* tags of a served app module's `html` templates, the same transform the SSR
|
|
@@ -767,7 +794,7 @@ export async function stripTs(source, _abs) {
|
|
|
767
794
|
*
|
|
768
795
|
* @param {string} abs
|
|
769
796
|
* @param {boolean} dev
|
|
770
|
-
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
|
|
797
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, lazyComponentFiles?: Map<string, string[]>, appDir: string }} [elideOpts]
|
|
771
798
|
* @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
|
|
772
799
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
773
800
|
* serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
|
|
@@ -841,6 +868,7 @@ export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDe
|
|
|
841
868
|
code = elideImportsFromSource(
|
|
842
869
|
code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
843
870
|
);
|
|
871
|
+
code = deferLazyImports(code, abs, elideOpts);
|
|
844
872
|
}
|
|
845
873
|
// Version same-origin relative import specifiers so the URL the browser
|
|
846
874
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
package/src/ssr/preloads.js
CHANGED
|
@@ -35,12 +35,17 @@ export function toUrlPath(file, appDir) {
|
|
|
35
35
|
* are stripped from the served source, so preloading their module would
|
|
36
36
|
* fetch JS the browser never executes.
|
|
37
37
|
*
|
|
38
|
+
* A tag counts as lazy when its class says so at runtime (`isLazy`) OR its
|
|
39
|
+
* file declares `static lazy = true` in source (`lazyFiles`, #1524), the same
|
|
40
|
+
* per-file verdict the served-source rewrite uses, so the two never disagree.
|
|
41
|
+
*
|
|
38
42
|
* @param {Set<string>} usedTags
|
|
39
43
|
* @param {string} appDir
|
|
40
44
|
* @param {Set<string>} [elidable] absolute paths of elidable component files
|
|
45
|
+
* @param {Map<string, string[]>} [lazyFiles] lazy component file -> its tags
|
|
41
46
|
* @returns {{ eager: string[], lazy: Record<string, string> }}
|
|
42
47
|
*/
|
|
43
|
-
export function componentPreloads(usedTags, appDir, elidable) {
|
|
48
|
+
export function componentPreloads(usedTags, appDir, elidable, lazyFiles) {
|
|
44
49
|
const eager = [];
|
|
45
50
|
/** @type {Record<string, string>} */
|
|
46
51
|
const lazy = {};
|
|
@@ -52,7 +57,7 @@ export function componentPreloads(usedTags, appDir, elidable) {
|
|
|
52
57
|
if (!abs.startsWith(appDir)) continue;
|
|
53
58
|
if (elidable && elidable.has(abs)) continue;
|
|
54
59
|
const url = toUrlPath(abs, appDir);
|
|
55
|
-
if (isLazy(tag)) {
|
|
60
|
+
if (isLazy(tag) || (lazyFiles && lazyFiles.has(abs))) {
|
|
56
61
|
lazy[tag] = url;
|
|
57
62
|
} else {
|
|
58
63
|
eager.push(url);
|
package/src/ssr/render.js
CHANGED
|
@@ -1105,13 +1105,23 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1105
1105
|
// registers; one nested behind an emitted component is absent here and
|
|
1106
1106
|
// loads via its importer's own imports. Dedup so a component shared
|
|
1107
1107
|
// across the page and a layout (or two layouts) is emitted once.
|
|
1108
|
+
//
|
|
1109
|
+
// A `static lazy` component in that frontier (#1524) is registered with the
|
|
1110
|
+
// lazy loader instead of imported, exactly as a shipping module's served
|
|
1111
|
+
// source defers its side-effect import of one, so the boot never fetches
|
|
1112
|
+
// it up front and a tag revealed later still loads on first sight.
|
|
1108
1113
|
const inert = opts.inertRouteModules;
|
|
1109
1114
|
const importOnly = opts.importOnlyRouteModules;
|
|
1115
|
+
const lazyFiles = opts.lazyComponentFiles;
|
|
1110
1116
|
const moduleUrls = [];
|
|
1117
|
+
/** @type {Record<string, string>} */
|
|
1118
|
+
const frontierLazy = {};
|
|
1111
1119
|
{
|
|
1112
1120
|
const seen = new Set();
|
|
1113
1121
|
const push = (abs) => {
|
|
1114
1122
|
const u = toUrlPath(abs, opts.appDir);
|
|
1123
|
+
const tags = lazyFiles && lazyFiles.get(abs);
|
|
1124
|
+
if (tags) { for (const t of tags) frontierLazy[t] = u; return; }
|
|
1115
1125
|
if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
|
|
1116
1126
|
};
|
|
1117
1127
|
for (const f of [route.file, ...route.layouts]) {
|
|
@@ -1137,8 +1147,15 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1137
1147
|
// the same preload twice. Lazy components are excluded from
|
|
1138
1148
|
// preloads and instead loaded via IntersectionObserver when they
|
|
1139
1149
|
// enter the viewport.
|
|
1140
|
-
const { eager: eagerComponents, lazy:
|
|
1141
|
-
componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
|
|
1150
|
+
const { eager: eagerComponents, lazy: renderedLazy } =
|
|
1151
|
+
componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents, lazyFiles);
|
|
1152
|
+
const lazyComponents = { ...frontierLazy, ...renderedLazy };
|
|
1153
|
+
// The preload walks skip a lazy component and the subtree reachable only
|
|
1154
|
+
// through it (#1524), the same way they skip an elided one: the browser
|
|
1155
|
+
// does not fetch it until the loader asks.
|
|
1156
|
+
const preloadSkip = lazyFiles && lazyFiles.size
|
|
1157
|
+
? new Set([...(opts.elidableComponents || []), ...lazyFiles.keys()])
|
|
1158
|
+
: opts.elidableComponents;
|
|
1142
1159
|
// The walk roots for BOTH preload passes are the BOOT's actually-shipped
|
|
1143
1160
|
// module set (`moduleUrls`, which already drops inert page/layout modules and
|
|
1144
1161
|
// substitutes an import-only page with its components), NOT the raw route
|
|
@@ -1155,7 +1172,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1155
1172
|
shippedRoots,
|
|
1156
1173
|
opts.appDir,
|
|
1157
1174
|
opts.serverFiles,
|
|
1158
|
-
|
|
1175
|
+
preloadSkip,
|
|
1159
1176
|
);
|
|
1160
1177
|
// Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
|
|
1161
1178
|
// vendor URLs the page's SHIPPED modules actually import, fetched in parallel
|
|
@@ -1169,7 +1186,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1169
1186
|
shippedRoots,
|
|
1170
1187
|
eagerComponents,
|
|
1171
1188
|
opts.appDir,
|
|
1172
|
-
|
|
1189
|
+
preloadSkip,
|
|
1173
1190
|
opts.serverFiles,
|
|
1174
1191
|
),
|
|
1175
1192
|
);
|