@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "pnext": "bin/pnext"
@@ -3260,40 +3260,66 @@ async function fetchPage(
3260
3260
  }
3261
3261
  if (segmentPayload) {
3262
3262
  segmentCacheKey = segmentDocumentCacheKey(treeResponse, href, options.prefetch!);
3263
- if (treeResponse.body && !treeResponse.bodyUsed) {
3264
- treePayload = parseSegmentTreePayload(await treeResponse.text());
3265
- }
3266
- // Remember this route's tree so a SIBLING URL of the same route never
3267
- // asks for it again (Next keys the route tree by route, not by URL).
3268
- const treeUrl = new URL(href, location.href);
3269
- learnRouteTree(
3270
- treeUrl.pathname,
3271
- treeUrl.search,
3272
- treePayload?.route,
3273
- treeResponse.headers.has('x-nextjs-rewritten-path') ||
3274
- treeResponse.headers.has('x-nextjs-rewritten-query'),
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) return 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) return null;
3283
- if (needsOutlinedHeadFirst(href, treePayload?.headFirst === true)) {
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) return withOutlinedHead(covered, outlinedHeadHtml);
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))) return null;
3292
- response = await fetchWithRedirectReplay(
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
- return withRewriteHeaders(
953
- withDeploymentId(
954
- treePrefetchResponse(payload, { format: url.searchParams.has('_rsc') ? 'flight' : 'json' }),
955
- ),
956
- rewriteHeaders,
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
  /**
@@ -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 args = source.slice(call.open + 1, call.close);
93
- const literal = /^(\s*)(['"])([^'"]+)\2(?=\s*(?:,|$))/.exec(args);
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: call.open + 1 + match.length,
101
- value: `() => import(${quote}${specifier}${quote})`,
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 = dynamicImportTarget.exec(call.source);
130
- if (!target?.[2]) continue;
131
- const file = resolve(target[2]);
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[4] ?? 'default' });
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
- const dynamicImportTarget =
155
- /import\s*\(\s*(['"])([^'"]+)\1\s*\)(?:\s*\.then\s*\(\s*([A-Za-z_$][\w$]*)\s*=>\s*\3\.([A-Za-z_$][\w$]*)\s*\))?/;
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 = dynamicImportTarget.exec(call.source);
182
- if (!target?.[2] || !deferred.has(target[2])) continue;
183
- const resolved = resolve(target[2]);
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[4] ?? 'default' });
195
+ const url = href({ file: resolved, exportName: target.exportName });
186
196
  if (!url) continue;
187
- const specifierAt = call.index + target.index + target[0].indexOf(target[2]);
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 = dynamicImportTarget.exec(call.source);
217
- if (!target?.[2]) continue;
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 args = source.slice(call.open + 1, call.close);
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[2]);
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 —
@@ -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 dynamicPattern =
2280
- /import\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.then\s*\(\s*([A-Za-z_$][\w$]*)\s*=>\s*\2\.([A-Za-z_$][\w$]*)\s*\))?/g;
2281
- let match: RegExpExecArray | null;
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 ?? 'default'],
2293
- dynamic: dynamicOptionsForImport(source, dynamicPattern.lastIndex, optionObjects),
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 isDynamicCallImport(source: string, importIndex: number, dynamicNames: Set<string>) {
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
- endIndex: number,
2308
+ fact: DynamicCallFact,
2332
2309
  optionObjects: Map<string, ClientDynamicReference>,
2333
2310
  ) {
2334
- const tail = source.slice(endIndex, endIndex + 300);
2335
- const inlineMatch = /^\s*,\s*({[\s\S]*?})\s*,?\s*\)/.exec(tail);
2336
- if (inlineMatch?.[1]) return dynamicOptionsFromSource(inlineMatch[1]);
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 {