@wular/pnext 0.0.3 → 0.0.4
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/api/router/runtime.ts +50 -19
- package/src/compat/register/segment.ts +16 -6
- package/src/dynamic/source.ts +36 -27
- package/src/resolve/scan-facts.ts +234 -0
- package/src/routing/routes.ts +12 -39
package/package.json
CHANGED
|
@@ -3260,40 +3260,66 @@ async function fetchPage(
|
|
|
3260
3260
|
}
|
|
3261
3261
|
if (segmentPayload) {
|
|
3262
3262
|
segmentCacheKey = segmentDocumentCacheKey(treeResponse, href, options.prefetch!);
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
//
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3263
|
+
// The tree PAYLOAD is only bookkeeping (route learning, headFirst): read it
|
|
3264
|
+
// without blocking the segment phase, so the body request goes on the wire in
|
|
3265
|
+
// the same task the tree HEADERS resolve in — before the tree body's own
|
|
3266
|
+
// stream-close task, where a harness batch (router-act) may already drain.
|
|
3267
|
+
const treeText =
|
|
3268
|
+
treeResponse.body && !treeResponse.bodyUsed ? treeResponse.text() : null;
|
|
3269
|
+
const finishTree = async () => {
|
|
3270
|
+
if (treeText) treePayload = parseSegmentTreePayload(await treeText);
|
|
3271
|
+
// Remember this route's tree so a SIBLING URL of the same route never
|
|
3272
|
+
// asks for it again (Next keys the route tree by route, not by URL).
|
|
3273
|
+
const treeUrl = new URL(href, location.href);
|
|
3274
|
+
learnRouteTree(
|
|
3275
|
+
treeUrl.pathname,
|
|
3276
|
+
treeUrl.search,
|
|
3277
|
+
treePayload?.route,
|
|
3278
|
+
treeResponse.headers.has('x-nextjs-rewritten-path') ||
|
|
3279
|
+
treeResponse.headers.has('x-nextjs-rewritten-query'),
|
|
3280
|
+
);
|
|
3281
|
+
};
|
|
3276
3282
|
releaseSlot();
|
|
3277
3283
|
const cached = getSegmentDocument(segmentCacheKey, href, options.prefetch!);
|
|
3278
|
-
if (cached)
|
|
3284
|
+
if (cached) {
|
|
3285
|
+
await finishTree();
|
|
3286
|
+
return cached;
|
|
3287
|
+
}
|
|
3279
3288
|
// The link left the viewport while the tree was in flight: stop before
|
|
3280
3289
|
// the segment phase (the in-flight tree request is never aborted, but no
|
|
3281
3290
|
// follow-up request may be issued — Next's cancellation contract).
|
|
3282
|
-
if (task?.cancelled)
|
|
3283
|
-
|
|
3291
|
+
if (task?.cancelled) {
|
|
3292
|
+
await finishTree();
|
|
3293
|
+
return null;
|
|
3294
|
+
}
|
|
3295
|
+
// Head-before-body order is preserved: the tree response HEADERS announce
|
|
3296
|
+
// outlining (x-pnext-head-outlined), so no tree-body read is needed here.
|
|
3297
|
+
const treeHeadOutlined = treeResponse.headers.get('x-pnext-head-outlined');
|
|
3298
|
+
if (needsOutlinedHeadFirst(href, treeHeadOutlined === 'first')) {
|
|
3299
|
+
await finishTree();
|
|
3284
3300
|
outlinedHeadHtml = await fetchOutlinedHead(href, init, headers, rscVariant);
|
|
3285
3301
|
}
|
|
3286
3302
|
const covered = segmentBodyCovered(href, options.prefetch);
|
|
3287
|
-
if (covered)
|
|
3303
|
+
if (covered) {
|
|
3304
|
+
await finishTree();
|
|
3305
|
+
return withOutlinedHead(covered, outlinedHeadHtml);
|
|
3306
|
+
}
|
|
3288
3307
|
bodySegment = prefetchBodySegmentPath(href);
|
|
3289
3308
|
headers[SEGMENT_PREFETCH_HEADER] = bodySegment;
|
|
3290
3309
|
requestHref = withRscQuery(href, `${rscVariant}:${bodySegment}`);
|
|
3291
|
-
if (!(await acquireSlot(PREFETCH_PHASE_SEGMENT)))
|
|
3292
|
-
|
|
3310
|
+
if (!(await acquireSlot(PREFETCH_PHASE_SEGMENT))) {
|
|
3311
|
+
await finishTree();
|
|
3312
|
+
return null;
|
|
3313
|
+
}
|
|
3314
|
+
const bodyPromise = fetchWithRedirectReplay(
|
|
3293
3315
|
requestHref,
|
|
3294
3316
|
init,
|
|
3295
3317
|
`${rscVariant}:${bodySegment}`,
|
|
3296
3318
|
);
|
|
3319
|
+
// A rejection before the await below must not surface as unhandled.
|
|
3320
|
+
bodyPromise.catch(() => undefined);
|
|
3321
|
+
await finishTree();
|
|
3322
|
+
response = await bodyPromise;
|
|
3297
3323
|
} else {
|
|
3298
3324
|
response = treeResponse;
|
|
3299
3325
|
}
|
|
@@ -4036,6 +4062,11 @@ async function pageForNavigation(
|
|
|
4036
4062
|
// prerendered document for the SAME pathname is already cached: a static prerender never
|
|
4037
4063
|
// rendered search params server-side, so only the client-visible URL differs.
|
|
4038
4064
|
if (!cached.settled) {
|
|
4065
|
+
// Same principle for a networkFree per-segment hit: it commits with zero
|
|
4066
|
+
// network, so waiting out the in-flight prefetch would be pure latency.
|
|
4067
|
+
if (segmentHit?.networkFree && !slotStateSensitive(departureNavState, segmentHit.html)) {
|
|
4068
|
+
return { html: segmentHit.html, finalUrl: url.href, ok: true };
|
|
4069
|
+
}
|
|
4039
4070
|
const shared = await samePathnamePrerenderedPage(url, now, departureNavState);
|
|
4040
4071
|
if (shared) return shared;
|
|
4041
4072
|
}
|
|
@@ -918,6 +918,11 @@ const segmentPrefetchInterceptor: RequestInterceptor = async (request, ctx) => {
|
|
|
918
918
|
'x-nextjs-postponed': '2',
|
|
919
919
|
'x-nextjs-stale-time': String(staleTime),
|
|
920
920
|
...(isStatic ? { 'x-nextjs-prerender': '1' } : {}),
|
|
921
|
+
// Announce head outlining on the tree HEADERS too, so the client
|
|
922
|
+
// can keep head-before-body order without reading the tree body.
|
|
923
|
+
...(selection?.route.pprMetadata === true
|
|
924
|
+
? { 'x-pnext-head-outlined': headFetchedFirst(selection) ? 'first' : '1' }
|
|
925
|
+
: {}),
|
|
921
926
|
'x-nextjs-deployment-id': deploymentId(),
|
|
922
927
|
// A static route's baked tree is CDN-cacheable like Next's
|
|
923
928
|
// prerendered payloads; the per-variant `_rsc` cache-buster keys
|
|
@@ -949,12 +954,17 @@ const segmentPrefetchInterceptor: RequestInterceptor = async (request, ctx) => {
|
|
|
949
954
|
? { headOutlined: true, ...(headFetchedFirst(selection) ? { headFirst: true } : {}) }
|
|
950
955
|
: {}),
|
|
951
956
|
});
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
957
|
+
const treeResponse = treePrefetchResponse(payload, {
|
|
958
|
+
format: url.searchParams.has('_rsc') ? 'flight' : 'json',
|
|
959
|
+
});
|
|
960
|
+
// Mirror the payload's head-outlining on the HEADERS (see baked branch).
|
|
961
|
+
if (selection?.route.pprMetadata === true) {
|
|
962
|
+
treeResponse.headers.set(
|
|
963
|
+
'x-pnext-head-outlined',
|
|
964
|
+
headFetchedFirst(selection) ? 'first' : '1',
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
return withRewriteHeaders(withDeploymentId(treeResponse), rewriteHeaders);
|
|
958
968
|
};
|
|
959
969
|
|
|
960
970
|
/**
|
package/src/dynamic/source.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rewriteFacts } from '../resolve/scan-facts';
|
|
1
|
+
import { dynamicCallFacts, rewriteFacts, type DynamicCallFact } from '../resolve/scan-facts';
|
|
2
2
|
|
|
3
3
|
export interface DynamicCall {
|
|
4
4
|
index: number;
|
|
@@ -86,19 +86,16 @@ export function dynamicCallsFromSource(source: string, dynamicNames: Set<string>
|
|
|
86
86
|
export function rewriteLiteralDynamicCalls(source: string, file?: string) {
|
|
87
87
|
const dynamicNames = pnextDynamicImportNames(source, file);
|
|
88
88
|
if (dynamicNames.size === 0) return source;
|
|
89
|
+
const facts = dynamicCallFacts(source, file);
|
|
89
90
|
|
|
90
91
|
const edits: { start: number; end: number; value: string }[] = [];
|
|
91
92
|
for (const call of dynamicCallsFromSource(source, dynamicNames)) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
if (!literal) continue;
|
|
95
|
-
const [match, leading = '', quote = "'", specifier] = literal;
|
|
96
|
-
if (!specifier) continue;
|
|
97
|
-
const start = call.open + 1 + leading.length;
|
|
93
|
+
const fact = factForCall(facts, call);
|
|
94
|
+
if (!fact?.literal) continue;
|
|
98
95
|
edits.push({
|
|
99
|
-
start,
|
|
100
|
-
end:
|
|
101
|
-
value: `() => import(${
|
|
96
|
+
start: fact.specifierStart,
|
|
97
|
+
end: fact.specifierEnd,
|
|
98
|
+
value: `() => import(${source.slice(fact.specifierStart, fact.specifierEnd)})`,
|
|
102
99
|
});
|
|
103
100
|
}
|
|
104
101
|
|
|
@@ -123,18 +120,19 @@ export function rewriteDynamicCallTargets(
|
|
|
123
120
|
) {
|
|
124
121
|
const dynamicNames = pnextDynamicImportNames(source, file);
|
|
125
122
|
if (dynamicNames.size === 0) return source;
|
|
123
|
+
const facts = dynamicCallFacts(source, file);
|
|
126
124
|
|
|
127
125
|
const edits: { at: number; value: string }[] = [];
|
|
128
126
|
for (const call of dynamicCallsFromSource(source, dynamicNames)) {
|
|
129
|
-
const target =
|
|
130
|
-
if (!target
|
|
131
|
-
const file = resolve(target
|
|
127
|
+
const target = loaderImportTargetForCall(call, facts);
|
|
128
|
+
if (!target) continue;
|
|
129
|
+
const file = resolve(target.specifier);
|
|
132
130
|
if (!file) continue;
|
|
133
131
|
|
|
134
132
|
const args = source.slice(call.open + 1, call.close);
|
|
135
133
|
const argCount = topLevelArgCount(args);
|
|
136
134
|
if (argCount < 1 || argCount > 2) continue;
|
|
137
|
-
const literal = JSON.stringify({ file, exportName: target
|
|
135
|
+
const literal = JSON.stringify({ file, exportName: target.exportName });
|
|
138
136
|
const separator = /,\s*$/.test(args) ? '' : ', ';
|
|
139
137
|
edits.push({
|
|
140
138
|
at: call.close,
|
|
@@ -151,8 +149,19 @@ export function rewriteDynamicCallTargets(
|
|
|
151
149
|
return next;
|
|
152
150
|
}
|
|
153
151
|
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
function factForCall(facts: DynamicCallFact[], call: DynamicCall) {
|
|
153
|
+
return facts.find(fact => fact.start === call.index);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The statically-extractable import behind one dynamic() call — the AST fact
|
|
158
|
+
* alone; a call the scan could not analyze yields no detection. Literal-loader
|
|
159
|
+
* calls (`dynamic('./x')`) answer undefined — they carry no `import()` yet.
|
|
160
|
+
*/
|
|
161
|
+
function loaderImportTargetForCall(call: DynamicCall, facts: DynamicCallFact[]) {
|
|
162
|
+
const fact = factForCall(facts, call);
|
|
163
|
+
return fact && !fact.literal ? fact : undefined;
|
|
164
|
+
}
|
|
156
165
|
|
|
157
166
|
/** Dev split of `dynamic(ssr:false)` targets; `PNEXT_DYNAMIC_SPLIT=0` restores eager builds. */
|
|
158
167
|
export function devDynamicSplitEnabled() {
|
|
@@ -175,17 +184,17 @@ export function rewriteDeferredDynamicImports(
|
|
|
175
184
|
if (dynamicNames.size === 0) return source;
|
|
176
185
|
const deferred = deferredDynamicImportSpecifiers(source, file);
|
|
177
186
|
if (deferred.size === 0) return source;
|
|
187
|
+
const facts = dynamicCallFacts(source, file);
|
|
178
188
|
|
|
179
189
|
const edits: { start: number; end: number; value: string }[] = [];
|
|
180
190
|
for (const call of dynamicCallsFromSource(source, dynamicNames)) {
|
|
181
|
-
const target =
|
|
182
|
-
if (!target
|
|
183
|
-
const resolved = resolve(target
|
|
191
|
+
const target = loaderImportTargetForCall(call, facts);
|
|
192
|
+
if (!target || !deferred.has(target.specifier)) continue;
|
|
193
|
+
const resolved = resolve(target.specifier);
|
|
184
194
|
if (!resolved) continue;
|
|
185
|
-
const url = href({ file: resolved, exportName: target
|
|
195
|
+
const url = href({ file: resolved, exportName: target.exportName });
|
|
186
196
|
if (!url) continue;
|
|
187
|
-
|
|
188
|
-
edits.push({ start: specifierAt, end: specifierAt + target[2].length, value: url });
|
|
197
|
+
edits.push({ start: target.specifierStart + 1, end: target.specifierEnd - 1, value: url });
|
|
189
198
|
}
|
|
190
199
|
if (edits.length === 0) return source;
|
|
191
200
|
let next = source;
|
|
@@ -212,13 +221,13 @@ export function deferredDynamicImportSpecifiers(source: string, file?: string) {
|
|
|
212
221
|
if (constMatch[1] && constMatch[2]) optionConsts.set(constMatch[1], constMatch[2]);
|
|
213
222
|
}
|
|
214
223
|
|
|
224
|
+
const facts = dynamicCallFacts(source, file);
|
|
215
225
|
for (const call of dynamicCallsFromSource(source, dynamicNames)) {
|
|
216
|
-
const target =
|
|
217
|
-
if (!target
|
|
226
|
+
const target = loaderImportTargetForCall(call, facts);
|
|
227
|
+
if (!target) continue;
|
|
218
228
|
// Token scan over the call plus any named option consts it references —
|
|
219
229
|
// tolerant of the compile-appended target argument (rewriteDynamicCallTargets).
|
|
220
|
-
const
|
|
221
|
-
const tail = args.slice(args.indexOf(target[0]) + target[0].length);
|
|
230
|
+
const tail = source.slice(target.loaderEnd, call.close);
|
|
222
231
|
let options = tail;
|
|
223
232
|
for (const identifier of tail.match(/[A-Za-z_$][\w$]*/g) ?? []) {
|
|
224
233
|
const named = optionConsts.get(identifier);
|
|
@@ -227,7 +236,7 @@ export function deferredDynamicImportSpecifiers(source: string, file?: string) {
|
|
|
227
236
|
const ssrFalse = /\bssr\s*:\s*false\b/.test(options);
|
|
228
237
|
const visible =
|
|
229
238
|
/\bload\s*:\s*['"]visible['"]/.test(options) && !/\bssr\s*:\s*true\b/.test(options);
|
|
230
|
-
if (ssrFalse || visible) deferred.add(target
|
|
239
|
+
if (ssrFalse || visible) deferred.add(target.specifier);
|
|
231
240
|
}
|
|
232
241
|
return deferred;
|
|
233
242
|
}
|
|
@@ -75,6 +75,33 @@ export interface RewriteFacts {
|
|
|
75
75
|
unreliable: boolean;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A `name(loader, options?)` call whose loader statically targets one module —
|
|
80
|
+
* the shape `dynamic()` extraction needs. Emitted for every identifier-callee
|
|
81
|
+
* call with such a loader; consumers gate on their own `dynamic` binding set.
|
|
82
|
+
*/
|
|
83
|
+
export interface DynamicCallFact {
|
|
84
|
+
name: string;
|
|
85
|
+
/** Callee identifier start — aligns with the textual call scan's index. */
|
|
86
|
+
start: number;
|
|
87
|
+
/** Call end (past the closing paren). */
|
|
88
|
+
end: number;
|
|
89
|
+
/** Loader target, cooked value. */
|
|
90
|
+
specifier: string;
|
|
91
|
+
/** Span of the quoted specifier literal, quotes included. */
|
|
92
|
+
specifierStart: number;
|
|
93
|
+
specifierEnd: number;
|
|
94
|
+
/** From the `.then(m => m.X)` arm (or awaited member); `default` otherwise. */
|
|
95
|
+
exportName: string;
|
|
96
|
+
/** First arg is a string literal (`dynamic('./x')` form), not a loader fn. */
|
|
97
|
+
literal?: boolean;
|
|
98
|
+
/** End of the loader argument — options/appended args follow it. */
|
|
99
|
+
loaderEnd: number;
|
|
100
|
+
/** Second-argument span, when present. */
|
|
101
|
+
optionsStart?: number;
|
|
102
|
+
optionsEnd?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
export interface ScanFacts {
|
|
79
106
|
/** Static value imports and `export … from` re-exports, in source order. */
|
|
80
107
|
imports: ScanEdge[];
|
|
@@ -88,6 +115,8 @@ export interface ScanFacts {
|
|
|
88
115
|
exportStars: string[];
|
|
89
116
|
/** A real `'use client'` directive — prologue position only, never a comment. */
|
|
90
117
|
useClient: boolean;
|
|
118
|
+
/** Loader-shaped calls (see `DynamicCallFact`); only sources that can bind `dynamic` are walked. */
|
|
119
|
+
dynamicCalls: DynamicCallFact[];
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
const stylesheetOrData = /\.(?:css|scss|sass|less|styl|json)$/;
|
|
@@ -99,6 +128,7 @@ const emptyFacts: ScanFacts = {
|
|
|
99
128
|
exportNames: [],
|
|
100
129
|
exportStars: [],
|
|
101
130
|
useClient: false,
|
|
131
|
+
dynamicCalls: [],
|
|
102
132
|
};
|
|
103
133
|
|
|
104
134
|
const emptyRewriteFacts: RewriteFacts = {
|
|
@@ -138,6 +168,15 @@ export function moduleExportStars(source: string, sourcefile = 'source.tsx') {
|
|
|
138
168
|
return scanFacts(sourcefile, source).exportStars;
|
|
139
169
|
}
|
|
140
170
|
|
|
171
|
+
/**
|
|
172
|
+
* AST-derived `dynamic()` loader facts — the single source of truth. A call
|
|
173
|
+
* without a fact is an unanalyzable loader: no detection, nothing to splice.
|
|
174
|
+
*/
|
|
175
|
+
export function dynamicCallFacts(source: string, file = 'module.tsx'): DynamicCallFact[] {
|
|
176
|
+
if (stylesheetOrData.test(file)) return [];
|
|
177
|
+
return parsed(file, source).scan.dynamicCalls;
|
|
178
|
+
}
|
|
179
|
+
|
|
141
180
|
/** Module-record spans for the rewrite passes (see `RewriteFacts`). */
|
|
142
181
|
export function rewriteFacts(file: string, source: string): RewriteFacts {
|
|
143
182
|
if (stylesheetOrData.test(file)) return emptyRewriteFacts;
|
|
@@ -327,6 +366,12 @@ function parseFacts(file: string, source: string, lang: ParserLang) {
|
|
|
327
366
|
exportNames,
|
|
328
367
|
exportStars,
|
|
329
368
|
useClient: hasUseClient(source, result),
|
|
369
|
+
// `result.program` deserializes the whole AST: walk it only for sources
|
|
370
|
+
// that can bind `dynamic()` at all (same cheap gate as dynamic/source.ts).
|
|
371
|
+
dynamicCalls:
|
|
372
|
+
source.includes('@wular/pnext') || source.includes('next/dynamic')
|
|
373
|
+
? collectDynamicCalls(result.program as unknown as AstNode)
|
|
374
|
+
: [],
|
|
330
375
|
},
|
|
331
376
|
rewrite: {
|
|
332
377
|
edges,
|
|
@@ -469,6 +514,195 @@ function literalSpecifier(raw: string) {
|
|
|
469
514
|
return /^\s*(['"])([^'"]*)\1\s*$/.exec(raw)?.[2] || undefined;
|
|
470
515
|
}
|
|
471
516
|
|
|
517
|
+
interface AstNode {
|
|
518
|
+
type: string;
|
|
519
|
+
start: number;
|
|
520
|
+
end: number;
|
|
521
|
+
[key: string]: unknown;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isNode(value: unknown): value is AstNode {
|
|
525
|
+
return typeof value === 'object' && value !== null && typeof (value as AstNode).type === 'string';
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function child(node: AstNode | undefined, key: string): AstNode | undefined {
|
|
529
|
+
const value = node?.[key];
|
|
530
|
+
return isNode(value) ? value : undefined;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function collectDynamicCalls(program: AstNode): DynamicCallFact[] {
|
|
534
|
+
const calls: DynamicCallFact[] = [];
|
|
535
|
+
const visit = (value: unknown) => {
|
|
536
|
+
if (Array.isArray(value)) {
|
|
537
|
+
for (const item of value) visit(item);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (!isNode(value)) return;
|
|
541
|
+
if (value.type === 'CallExpression') {
|
|
542
|
+
const fact = dynamicCallFact(value);
|
|
543
|
+
if (fact) calls.push(fact);
|
|
544
|
+
}
|
|
545
|
+
for (const key in value) {
|
|
546
|
+
if (key === 'type' || key === 'loc' || key === 'range') continue;
|
|
547
|
+
const entry = value[key];
|
|
548
|
+
if (entry && typeof entry === 'object') visit(entry);
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
visit(program.body);
|
|
552
|
+
return calls.sort((a, b) => a.start - b.start);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function dynamicCallFact(call: AstNode): DynamicCallFact | undefined {
|
|
556
|
+
const callee = child(call, 'callee');
|
|
557
|
+
if (callee?.type !== 'Identifier') return undefined;
|
|
558
|
+
const args = call.arguments;
|
|
559
|
+
if (!Array.isArray(args) || !isNode(args[0])) return undefined;
|
|
560
|
+
const loader = args[0];
|
|
561
|
+
|
|
562
|
+
let target: { specifier: AstNode; exportName: string } | undefined;
|
|
563
|
+
let literal: boolean | undefined;
|
|
564
|
+
if (stringValue(loader) !== undefined) {
|
|
565
|
+
target = { specifier: loader, exportName: 'default' };
|
|
566
|
+
literal = true;
|
|
567
|
+
} else if (loader.type === 'ArrowFunctionExpression' || loader.type === 'FunctionExpression') {
|
|
568
|
+
target = loaderImportTarget(loader) ?? firstImportTarget(loader);
|
|
569
|
+
}
|
|
570
|
+
if (!target) return undefined;
|
|
571
|
+
|
|
572
|
+
const specifier = stringValue(target.specifier);
|
|
573
|
+
if (specifier === undefined) return undefined;
|
|
574
|
+
const options = isNode(args[1]) ? args[1] : undefined;
|
|
575
|
+
return {
|
|
576
|
+
name: callee.name as string,
|
|
577
|
+
start: callee.start,
|
|
578
|
+
end: call.end,
|
|
579
|
+
specifier,
|
|
580
|
+
specifierStart: target.specifier.start,
|
|
581
|
+
specifierEnd: target.specifier.end,
|
|
582
|
+
exportName: target.exportName,
|
|
583
|
+
...(literal ? { literal } : {}),
|
|
584
|
+
loaderEnd: loader.end,
|
|
585
|
+
...(options ? { optionsStart: options.start, optionsEnd: options.end } : {}),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function stringValue(node: AstNode | undefined): string | undefined {
|
|
590
|
+
if (!node) return undefined;
|
|
591
|
+
if (node.type !== 'Literal' && node.type !== 'StringLiteral') return undefined;
|
|
592
|
+
return typeof node.value === 'string' ? node.value : undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Past parens, `await`, `as`/`!` and optional-chain wrappers to the loader's real expression. */
|
|
596
|
+
function unwrapExpression(node: AstNode | undefined): AstNode | undefined {
|
|
597
|
+
let current = node;
|
|
598
|
+
for (;;) {
|
|
599
|
+
if (!current) return undefined;
|
|
600
|
+
if (current.type === 'ParenthesizedExpression' || current.type === 'ChainExpression') {
|
|
601
|
+
current = child(current, 'expression');
|
|
602
|
+
} else if (current.type === 'AwaitExpression') {
|
|
603
|
+
current = child(current, 'argument');
|
|
604
|
+
} else if (current.type === 'TSAsExpression' || current.type === 'TSNonNullExpression' || current.type === 'TSSatisfiesExpression') {
|
|
605
|
+
current = child(current, 'expression');
|
|
606
|
+
} else {
|
|
607
|
+
return current;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The expression a loader function resolves to: its expression body or first `return`. */
|
|
613
|
+
function loaderResult(fn: AstNode): AstNode | undefined {
|
|
614
|
+
const body = child(fn, 'body');
|
|
615
|
+
if (!body) return undefined;
|
|
616
|
+
if (body.type !== 'BlockStatement' && body.type !== 'FunctionBody') return unwrapExpression(body);
|
|
617
|
+
const statements = body.body;
|
|
618
|
+
if (!Array.isArray(statements)) return undefined;
|
|
619
|
+
for (const statement of statements) {
|
|
620
|
+
if (isNode(statement) && statement.type === 'ReturnStatement') {
|
|
621
|
+
return unwrapExpression(child(statement, 'argument'));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function importSource(node: AstNode | undefined): AstNode | undefined {
|
|
628
|
+
if (node?.type !== 'ImportExpression') return undefined;
|
|
629
|
+
const source = child(node, 'source');
|
|
630
|
+
return source && stringValue(source) !== undefined ? source : undefined;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Statically resolve a loader body: `import('x')`, `import('x').then(m => m.X)`
|
|
635
|
+
* (any param/arrow formatting), or `(await import('x')).X`.
|
|
636
|
+
*/
|
|
637
|
+
function loaderImportTarget(fn: AstNode): { specifier: AstNode; exportName: string } | undefined {
|
|
638
|
+
const result = loaderResult(fn);
|
|
639
|
+
if (!result) return undefined;
|
|
640
|
+
|
|
641
|
+
const direct = importSource(result);
|
|
642
|
+
if (direct) return { specifier: direct, exportName: 'default' };
|
|
643
|
+
|
|
644
|
+
if (result.type === 'MemberExpression' && result.computed !== true) {
|
|
645
|
+
const source = importSource(unwrapExpression(child(result, 'object')));
|
|
646
|
+
const property = child(result, 'property');
|
|
647
|
+
if (source && property?.type === 'Identifier') {
|
|
648
|
+
return { specifier: source, exportName: property.name as string };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (result.type !== 'CallExpression') return undefined;
|
|
653
|
+
const callee = child(result, 'callee');
|
|
654
|
+
if (callee?.type !== 'MemberExpression' || callee.computed === true) return undefined;
|
|
655
|
+
if (child(callee, 'property')?.name !== 'then') return undefined;
|
|
656
|
+
const source = importSource(unwrapExpression(child(callee, 'object')));
|
|
657
|
+
if (!source) return undefined;
|
|
658
|
+
return { specifier: source, exportName: thenArmExport(result) ?? 'default' };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** `X` of a `.then(m => m.X)` arm; undefined for any other arm shape. */
|
|
662
|
+
function thenArmExport(thenCall: AstNode): string | undefined {
|
|
663
|
+
const args = thenCall.arguments;
|
|
664
|
+
if (!Array.isArray(args) || !isNode(args[0])) return undefined;
|
|
665
|
+
const arm = args[0];
|
|
666
|
+
if (arm.type !== 'ArrowFunctionExpression' && arm.type !== 'FunctionExpression') return undefined;
|
|
667
|
+
const params = arm.params;
|
|
668
|
+
const param = Array.isArray(params) && isNode(params[0]) ? params[0] : undefined;
|
|
669
|
+
if (param?.type !== 'Identifier') return undefined;
|
|
670
|
+
const result = loaderResult(arm);
|
|
671
|
+
if (result?.type !== 'MemberExpression' || result.computed === true) return undefined;
|
|
672
|
+
const object = unwrapExpression(child(result, 'object'));
|
|
673
|
+
if (object?.type !== 'Identifier' || object.name !== param.name) return undefined;
|
|
674
|
+
const property = child(result, 'property');
|
|
675
|
+
return property?.type === 'Identifier' ? (property.name as string) : undefined;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Fallback for loader shapes the structured pass cannot follow: the first
|
|
680
|
+
* literal `import()` anywhere in the loader, default export — mirrors what the
|
|
681
|
+
* textual extraction always did for these.
|
|
682
|
+
*/
|
|
683
|
+
function firstImportTarget(fn: AstNode): { specifier: AstNode; exportName: string } | undefined {
|
|
684
|
+
let found: AstNode | undefined;
|
|
685
|
+
const visit = (value: unknown) => {
|
|
686
|
+
if (found) return;
|
|
687
|
+
if (Array.isArray(value)) {
|
|
688
|
+
for (const item of value) visit(item);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (!isNode(value)) return;
|
|
692
|
+
const source = importSource(value);
|
|
693
|
+
if (source) {
|
|
694
|
+
found = source;
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
for (const key in value) {
|
|
698
|
+
const entry = value[key];
|
|
699
|
+
if (entry && typeof entry === 'object') visit(entry);
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
visit(fn.body);
|
|
703
|
+
return found ? { specifier: found, exportName: 'default' } : undefined;
|
|
704
|
+
}
|
|
705
|
+
|
|
472
706
|
type ParserLang = 'ts' | 'tsx';
|
|
473
707
|
|
|
474
708
|
// `.ts` forbids JSX (`<T>value` is a type assertion there); everything else —
|
package/src/routing/routes.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
resolveModuleAlias,
|
|
16
16
|
resolvePackageSpecifier,
|
|
17
17
|
} from '../resolve/imports';
|
|
18
|
-
import { scanFacts } from '../resolve/scan-facts';
|
|
18
|
+
import { dynamicCallFacts, scanFacts, type DynamicCallFact } from '../resolve/scan-facts';
|
|
19
19
|
import { readSourceSync } from '../resolve/source-text';
|
|
20
20
|
import { globalCssSourcesForPaths } from '../css';
|
|
21
21
|
import {
|
|
@@ -2276,21 +2276,14 @@ function dynamicImportEdgesFromSource(
|
|
|
2276
2276
|
if (dynamicNames.size === 0) return imports;
|
|
2277
2277
|
|
|
2278
2278
|
const optionObjects = dynamicOptionObjects(source);
|
|
2279
|
-
const
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
while ((match = dynamicPattern.exec(source))) {
|
|
2284
|
-
const [, specifier, _moduleName, exportName] = match;
|
|
2285
|
-
if (!specifier) continue;
|
|
2286
|
-
if (!isDynamicCallImport(source, match.index, dynamicNames)) continue;
|
|
2287
|
-
|
|
2288
|
-
const resolved = resolveModuleEdge(root, file, specifier);
|
|
2279
|
+
for (const fact of dynamicCallFacts(source, file)) {
|
|
2280
|
+
if (!dynamicNames.has(fact.name)) continue;
|
|
2281
|
+
const resolved = resolveModuleEdge(root, file, fact.specifier);
|
|
2289
2282
|
if (!resolved) continue;
|
|
2290
2283
|
imports.push({
|
|
2291
2284
|
file: resolved,
|
|
2292
|
-
exports: [exportName
|
|
2293
|
-
dynamic:
|
|
2285
|
+
exports: [fact.exportName],
|
|
2286
|
+
dynamic: dynamicOptionsForFact(source, fact, optionObjects),
|
|
2294
2287
|
});
|
|
2295
2288
|
}
|
|
2296
2289
|
|
|
@@ -2310,35 +2303,15 @@ function dynamicOptionObjects(source: string) {
|
|
|
2310
2303
|
return options;
|
|
2311
2304
|
}
|
|
2312
2305
|
|
|
2313
|
-
function
|
|
2314
|
-
const prefix = source.slice(Math.max(0, importIndex - 120), importIndex);
|
|
2315
|
-
const dynamicIndex = nextDynamicCallIndexFromEnd(prefix, dynamicNames);
|
|
2316
|
-
if (dynamicIndex === -1) return false;
|
|
2317
|
-
return !/[;\n]\s*$/.test(prefix.slice(dynamicIndex));
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
function nextDynamicCallIndexFromEnd(source: string, dynamicNames: Set<string>) {
|
|
2321
|
-
let next = -1;
|
|
2322
|
-
for (const name of dynamicNames) {
|
|
2323
|
-
const index = source.lastIndexOf(name);
|
|
2324
|
-
if (index !== -1 && index > next) next = index;
|
|
2325
|
-
}
|
|
2326
|
-
return next;
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
function dynamicOptionsForImport(
|
|
2306
|
+
function dynamicOptionsForFact(
|
|
2330
2307
|
source: string,
|
|
2331
|
-
|
|
2308
|
+
fact: DynamicCallFact,
|
|
2332
2309
|
optionObjects: Map<string, ClientDynamicReference>,
|
|
2333
2310
|
) {
|
|
2334
|
-
|
|
2335
|
-
const
|
|
2336
|
-
if (
|
|
2337
|
-
|
|
2338
|
-
const identifierMatch = /^\s*,\s*([A-Za-z_$][\w$]*)\s*,?\s*\)/.exec(tail);
|
|
2339
|
-
if (identifierMatch?.[1]) return optionObjects.get(identifierMatch[1]) ?? {};
|
|
2340
|
-
|
|
2341
|
-
return {};
|
|
2311
|
+
if (fact.optionsStart === undefined || fact.optionsEnd === undefined) return {};
|
|
2312
|
+
const options = source.slice(fact.optionsStart, fact.optionsEnd);
|
|
2313
|
+
if (/^[A-Za-z_$][\w$]*$/.test(options)) return optionObjects.get(options) ?? {};
|
|
2314
|
+
return dynamicOptionsFromSource(options);
|
|
2342
2315
|
}
|
|
2343
2316
|
|
|
2344
2317
|
function dynamicOptionsFromSource(source?: string): ClientDynamicReference {
|