@wular/pnext 0.0.2 → 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.
Files changed (64) hide show
  1. package/README.md +76 -20
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +75 -48
  5. package/src/api/router/runtime.ts +60 -21
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +37 -5
  8. package/src/cli/dev.ts +6 -0
  9. package/src/cli/index.ts +17 -3
  10. package/src/cli/request-pipeline.ts +1340 -0
  11. package/src/cli/server-entry.ts +180 -0
  12. package/src/cli/start.ts +39 -1311
  13. package/src/client/build.ts +59 -20
  14. package/src/client/chunk-fold.ts +40 -0
  15. package/src/client/compat-surface.ts +175 -0
  16. package/src/client/entry.ts +67 -52
  17. package/src/compat/actions/action-client.ts +8 -1
  18. package/src/compat/actions/action-dispatch.ts +11 -1
  19. package/src/compat/actions/discovery.ts +23 -6
  20. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  21. package/src/compat/bundler/worker.ts +2 -1
  22. package/src/compat/client/errors/bare-boundary.ts +32 -0
  23. package/src/compat/client/errors/error-boundary.ts +1 -15
  24. package/src/compat/client/errors/primitive-throw.ts +16 -0
  25. package/src/compat/client/link-status.ts +1 -1
  26. package/src/compat/css/lightningcss.ts +2 -1
  27. package/src/compat/css/modules.ts +4 -3
  28. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  29. package/src/compat/lifecycle/instrumentation.ts +5 -2
  30. package/src/compat/next/config-loader.ts +33 -5
  31. package/src/compat/next/dynamic.tsx +9 -5
  32. package/src/compat/next/link-validation-transform.ts +5 -1
  33. package/src/compat/next/link.tsx +51 -58
  34. package/src/compat/pages/client-plugin.ts +2 -1
  35. package/src/compat/react/action-state.ts +159 -0
  36. package/src/compat/react/client-lite.ts +74 -0
  37. package/src/compat/react/hooks-extra.ts +92 -0
  38. package/src/compat/react/parity.ts +128 -0
  39. package/src/compat/react/preact.ts +33 -420
  40. package/src/compat/react/server-inserted-html.ts +14 -7
  41. package/src/compat/react/use.ts +72 -0
  42. package/src/compat/register/actions.ts +27 -7
  43. package/src/compat/register/segment.ts +16 -6
  44. package/src/config.ts +15 -1
  45. package/src/css/build.ts +13 -2
  46. package/src/dev/imports.ts +34 -5
  47. package/src/dev/module-cache.ts +19 -0
  48. package/src/dev/module-transform.ts +7 -1
  49. package/src/dev/server.ts +91 -22
  50. package/src/dynamic/source.ts +36 -27
  51. package/src/ppr.ts +5 -4
  52. package/src/proxy.ts +5 -1
  53. package/src/render/island-context.ts +21 -3
  54. package/src/render/renderer.ts +102 -26
  55. package/src/resolve/engine.ts +12 -2
  56. package/src/resolve/scan-facts.ts +239 -1
  57. package/src/routing/href.ts +4 -5
  58. package/src/routing/routes.ts +26 -41
  59. package/src/runtime/server.ts +8 -4
  60. package/src/runtime/vendor.ts +1 -1
  61. package/src/typegen.ts +3 -3
  62. package/src/utils/esbuild.ts +58 -0
  63. package/src/utils/fs.ts +14 -2
  64. package/src/utils/native-require.ts +28 -0
@@ -38,6 +38,7 @@ import {
38
38
  clientRuntimeSource,
39
39
  type ClientRuntimeFacts,
40
40
  } from './entry';
41
+ import { clientSuspenseFree } from './compat-surface';
41
42
  import {
42
43
  ensurePrebuiltRuntime,
43
44
  prebuiltAssets,
@@ -142,14 +143,14 @@ function appUsesActions(routes: RouteManifestEntry[], hasServerActions?: boolean
142
143
  }
143
144
 
144
145
  /**
145
- * Whether this route has an `error.*` or `not-found.*` for the in-tree ClientErrorBoundary to
146
- * render. Without one the boundary would only re-throw, and the deferred window last resort already
147
- * owns that path - so the whole boundary cluster stays off first paint.
146
+ * Per-route half of the same question: the scan stamps `actions`/`form` reasons on every page whose
147
+ * closure can dispatch one, so a route carrying neither ships no action runtime even in an app with
148
+ * actions elsewhere.
148
149
  */
149
- function hasRouteErrorBoundary(config: ResolvedConfig, route: RouteManifestEntry): boolean {
150
+ function routeUsesActions(route: RouteManifestEntry, appActions: boolean) {
151
+ if (!appActions) return false;
150
152
  return (
151
- Boolean(routeErrorFile(config, route) ?? routeNotFoundFile(config, route)) ||
152
- routeThrowsClientControlFlow(route)
153
+ route.clientEntryReasons?.some(reason => reason === 'actions' || reason === 'form') === true
153
154
  );
154
155
  }
155
156
 
@@ -301,11 +302,10 @@ function surfaceSignature(config: ResolvedConfig, route: RouteManifestEntry, act
301
302
  {
302
303
  route,
303
304
  shell: hasClientRootLayout(config, route),
304
- boundary: hasRouteErrorBoundary(config, route),
305
- notFound: Boolean(routeNotFoundFile(config, route)),
306
305
  },
307
306
  ],
308
307
  nextCompat,
308
+ !routeSuspenseFree(config, route),
309
309
  );
310
310
  return JSON.stringify([
311
311
  nextCompat,
@@ -337,6 +337,7 @@ async function preparePrebuilt(
337
337
  dev: boolean,
338
338
  sourceOf: (importer: string) => string | undefined,
339
339
  signature = '',
340
+ reactLite = false,
340
341
  ): Promise<PreparedPrebuilt | undefined> {
341
342
  if (!prebuiltEnabled()) return undefined;
342
343
  const buildOptions = baseClientBuildOptions(config, dev);
@@ -353,7 +354,7 @@ async function preparePrebuilt(
353
354
  publicPath,
354
355
  assetPath,
355
356
  buildOptions,
356
- plugins: () => clientBuildPlugins(config, createClientSourcePipeline(config)),
357
+ plugins: () => clientBuildPlugins(config, createClientSourcePipeline(config), [], reactLite),
357
358
  };
358
359
  const runtime = await ensurePrebuiltRuntime(options);
359
360
  const unprobed = new Set<string>();
@@ -417,11 +418,13 @@ function deferredDynamicExternalPlugin(): Plugin {
417
418
  export async function buildClientEntry({ config, route, outDir, dev }: ClientBuildOptions) {
418
419
  conventionCache.clear();
419
420
  await ensureDir(outDir);
421
+ const suspense = routeSuspenseFree(config, route) === false;
420
422
  const source = clientEntrySource({
421
423
  deferredDynamicHref: devDeferredDynamicHref(dev),
422
424
  pageFile: route.client ? route.file : undefined,
423
425
  clientReferences: route.clientReferences,
424
426
  nextCompat: nextCompatEnabled(config),
427
+ suspense,
425
428
  // Single-route (dev / batch fallback) build: no app-wide action manifest here.
426
429
  actions: true,
427
430
  errorFile: routeErrorFile(config, route),
@@ -441,11 +444,10 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
441
444
  {
442
445
  route,
443
446
  shell: hasClientRootLayout(config, route),
444
- boundary: hasRouteErrorBoundary(config, route),
445
- notFound: Boolean(routeNotFoundFile(config, route)),
446
447
  },
447
448
  ],
448
449
  nextCompatEnabled(config),
450
+ suspense,
449
451
  );
450
452
  const prebuilt = await clientProfile.timeAsync('prebuilt', () => preparePrebuilt(
451
453
  config,
@@ -458,6 +460,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
458
460
  // One route is its own surface group, so the key is the same one the batch
459
461
  // build would give this route's group — dev and prod derive it identically.
460
462
  surfaceSignature(config, route, true),
463
+ !suspense,
461
464
  ));
462
465
 
463
466
  let metafile: Metafile | undefined;
@@ -478,7 +481,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
478
481
  ...(prebuilt ? [prebuilt.plugin] : []),
479
482
  ...(devDeferredDynamicHref(dev) ? [deferredDynamicExternalPlugin()] : []),
480
483
  clientRuntimePlugin(runtimeFacts),
481
- ]),
484
+ ], !suspense),
482
485
  })),
483
486
  );
484
487
  metafile = result.metafile;
@@ -611,7 +614,8 @@ export async function buildClientEntries({
611
614
  pageFile: route.client ? route.file : undefined,
612
615
  clientReferences: route.clientReferences,
613
616
  nextCompat: nextCompatEnabled(config),
614
- actions,
617
+ suspense: !routeSuspenseFree(config, route),
618
+ actions: routeUsesActions(route, actions),
615
619
  errorFile: routeErrorFile(config, route),
616
620
  globalErrorFile: globalErrorFile(config),
617
621
  notFoundFile: routeNotFoundFile(config, route),
@@ -697,17 +701,16 @@ function surfaceGroups(
697
701
  members.map(entry => ({
698
702
  route: entry.route,
699
703
  shell: hasClientRootLayout(config, entry.route),
700
- boundary: hasRouteErrorBoundary(config, entry.route),
701
- notFound: Boolean(routeNotFoundFile(config, entry.route)),
702
704
  })),
703
705
  nextCompatEnabled(config),
706
+ !members.every(entry => routeSuspenseFree(config, entry.route)),
704
707
  );
705
708
  // eslint-disable-next-line turbo/no-undeclared-env-vars
706
709
  if (!prebuiltEnabled() || process.env.PNEXT_PREBUILT_GROUPS === '0')
707
710
  return [{ signature: '', facts: facts(entries), entries }];
708
711
  const bySignature = new Map<string, ClientEntry[]>();
709
712
  for (const entry of entries) {
710
- const signature = surfaceSignature(config, entry.route, actions);
713
+ const signature = surfaceSignature(config, entry.route, routeUsesActions(entry.route, actions));
711
714
  const members = bySignature.get(signature) ?? [];
712
715
  bySignature.set(signature, members);
713
716
  members.push(entry);
@@ -738,6 +741,7 @@ async function buildEntryGroup({
738
741
  pipeline: ClientSourcePipeline;
739
742
  verbose?: boolean;
740
743
  }): Promise<BuiltEntryGroup> {
744
+ const reactLite = group.facts.suspense === false;
741
745
  const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
742
746
  preparePrebuilt(
743
747
  config,
@@ -749,6 +753,7 @@ async function buildEntryGroup({
749
753
  return entry?.source ?? pipeline.sourceOf(importer) ?? readTextSyncSafe(importer);
750
754
  },
751
755
  group.signature,
756
+ reactLite,
752
757
  ),
753
758
  );
754
759
 
@@ -766,7 +771,7 @@ async function buildEntryGroup({
766
771
  ...(prebuilt ? [prebuilt.plugin] : []),
767
772
  virtualEntryPlugin(group.entries),
768
773
  clientRuntimePlugin(group.facts),
769
- ]),
774
+ ], reactLite),
770
775
  }));
771
776
  metafile = result.metafile;
772
777
  } catch (error) {
@@ -791,6 +796,30 @@ async function buildEntryGroup({
791
796
  * page's own file. Deliberately NOT a module walk: this set is free (the scan produced it), it is
792
797
  * the bulk of any app's first-party client graph, and whatever it misses esbuild still discovers.
793
798
  */
799
+ /**
800
+ * Whether this route's client graph provably never suspends (see clientSuspenseFree): its entry
801
+ * then drops the Suspense island wrapper and its `react` alias points at the compat-free lite shim.
802
+ * Memoized per build; a group of routes qualifies iff every member does.
803
+ */
804
+ function routeSuspenseFree(config: ResolvedConfig, route: RouteManifestEntry): boolean {
805
+ if (!nextCompatEnabled(config)) return false;
806
+ return memoConvention(`suspenseFree:${route.id}`, () => {
807
+ const seeds: string[] = [];
808
+ if (route.client) seeds.push(route.file);
809
+ for (const reference of route.clientReferences) seeds.push(reference.file);
810
+ for (const file of [
811
+ routeErrorFile(config, route),
812
+ globalErrorFile(config),
813
+ routeNotFoundFile(config, route),
814
+ // Shell layouts hydrate client-side only when the root layout is 'use client'.
815
+ ...(hasClientRootLayout(config, route) ? shellLayoutOrder(config, route) : []),
816
+ ]) {
817
+ if (file) seeds.push(file);
818
+ }
819
+ return clientSuspenseFree(seeds);
820
+ });
821
+ }
822
+
794
823
  function routeClientSources(routes: RouteManifestEntry[]) {
795
824
  const files = new Set<string>();
796
825
  for (const route of routes) {
@@ -1141,6 +1170,7 @@ function clientBuildPlugins(
1141
1170
  config: ResolvedConfig,
1142
1171
  pipeline: ClientSourcePipeline,
1143
1172
  extra: Plugin[] = [],
1173
+ reactLite = false,
1144
1174
  ): Plugin[] {
1145
1175
  return coalesceResolveHooks(instrumentedPlugins([
1146
1176
  serverOnlyClientImportPlugin(),
@@ -1153,13 +1183,18 @@ function clientBuildPlugins(
1153
1183
  linkedPackageClientResolvePlugin(config),
1154
1184
  clientStaticAssetPlugin(config),
1155
1185
  pipeline.plugin(),
1156
- importAliasPlugin(config),
1186
+ importAliasPlugin(config, reactLite),
1157
1187
  cssModuleClientPlugin(),
1158
1188
  ]), 'pnext-client-resolve-chain');
1159
1189
  }
1160
1190
 
1161
- function importAliasPlugin(config: ResolvedConfig): Plugin {
1162
- const aliases = getImportAliasExtensions().aliases(config, 'client');
1191
+ function importAliasPlugin(config: ResolvedConfig, reactLite = false): Plugin {
1192
+ const aliases = { ...getImportAliasExtensions().aliases(config, 'client') };
1193
+ // Suspense-free tier: the app's `react` imports resolve to the compat-free lite shim, so the
1194
+ // bundle ships preact core + hooks without preact/compat (see clientSuspenseFree).
1195
+ if (reactLite && aliases.react) {
1196
+ aliases.react = path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client-lite.ts');
1197
+ }
1163
1198
  const specifiers = Object.keys(aliases);
1164
1199
  return {
1165
1200
  name: 'pnext-import-alias',
@@ -2031,6 +2066,10 @@ function rootFromFile(file: string) {
2031
2066
  // the metafile: the package name for node_modules code, the file stem for app
2032
2067
  // code. Falls back to "shared" when a build ran without a metafile.
2033
2068
  async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
2069
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
2070
+ if (metafile && process.env.PNEXT_CLIENT_METAFILE) {
2071
+ await writeText(path.join(outDir, 'metafile.json'), JSON.stringify(metafile));
2072
+ }
2034
2073
  const chunksDir = path.join(outDir, 'chunks');
2035
2074
  const files = (await listFiles(chunksDir)).filter(file => file.endsWith('.js'));
2036
2075
  const labels = metafile ? chunkLabelsFromMetafile(metafile) : new Map<string, string>();
@@ -216,6 +216,42 @@ function availabilityAt(
216
216
  return answer;
217
217
  }
218
218
 
219
+ /**
220
+ * A dynamic chunk only runs on a page that already ran a route entry, so the intersection over every
221
+ * entry that can reach it is a guaranteed availability floor - `inherited` intersects over direct
222
+ * importers whose own availability is still an estimate, and lands pessimistic behind route chunks.
223
+ */
224
+ function entryFloors(metafile: Metafile, entries: string[], closures: Map<string, Set<string>>) {
225
+ const dynamicEdges = new Map<string, string[]>();
226
+ for (const [file, output] of Object.entries(metafile.outputs)) {
227
+ for (const imported of output.imports ?? []) {
228
+ if (imported.kind !== 'dynamic-import') continue;
229
+ (dynamicEdges.get(file) ?? dynamicEdges.set(file, []).get(file)!).push(imported.path);
230
+ }
231
+ }
232
+ const floors = new Map<string, Set<string>>();
233
+ for (const entry of entries) {
234
+ const own = closures.get(entry)!;
235
+ const seen = new Set(own);
236
+ const queue = [...own];
237
+ // `queue` grows inside the loop: every chunk this entry can reach by any
238
+ // chain of static and dynamic imports.
239
+ for (const current of queue) {
240
+ for (const target of dynamicEdges.get(current) ?? []) {
241
+ if (seen.has(target)) continue;
242
+ for (const file of closures.get(target) ?? staticClosure(metafile, target)) {
243
+ if (seen.has(file)) continue;
244
+ seen.add(file);
245
+ queue.push(file);
246
+ }
247
+ const floor = floors.get(target);
248
+ floors.set(target, floor ? intersect(floor, own) : new Set(own));
249
+ }
250
+ }
251
+ }
252
+ return floors;
253
+ }
254
+
219
255
  /** Per load root, the chunks guaranteed to be on the page once it runs. */
220
256
  function availability(metafile: Metafile, entries: string[], dynamicTargets: Set<string>) {
221
257
  const available = new Map<string, Set<string>>();
@@ -224,6 +260,10 @@ function availability(metafile: Metafile, entries: string[], dynamicTargets: Set
224
260
  closures.set(root, staticClosure(metafile, root));
225
261
  available.set(root, new Set(closures.get(root)));
226
262
  }
263
+ for (const [target, floor] of entryFloors(metafile, entries, closures)) {
264
+ const set = available.get(target);
265
+ if (set) for (const file of floor) set.add(file);
266
+ }
227
267
  const importersOf = new Map<string, string[]>();
228
268
  for (const [file, output] of Object.entries(metafile.outputs)) {
229
269
  for (const imported of output.imports ?? []) {
@@ -0,0 +1,175 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Conservative static scan of a compat app's client graph deciding whether the client bundle can ship
6
+ * the lite react tier (preact core + hooks, no preact/compat, no Suspense island wrapper). Walks the
7
+ * seed files' relative-import closure; ANY signal it cannot prove safe - an unresolvable import, a bare
8
+ * package, a non-lite react name, a compat-only DOM prop, an async component shape - keeps the full
9
+ * tier. False negatives cost bytes, never correctness.
10
+ */
11
+
12
+ /** react named imports the lite client shim (compat/react/client-lite.ts) serves. */
13
+ const LITE_REACT_NAMES = new Set([
14
+ 'Component',
15
+ 'Fragment',
16
+ 'StrictMode',
17
+ 'cache',
18
+ 'cacheSignal',
19
+ 'cloneElement',
20
+ 'createContext',
21
+ 'createElement',
22
+ 'createRef',
23
+ 'isValidElement',
24
+ 'startTransition',
25
+ 'useActionState',
26
+ 'useCallback',
27
+ 'useContext',
28
+ 'useDebugValue',
29
+ 'useDeferredValue',
30
+ 'useEffect',
31
+ 'useId',
32
+ 'useImperativeHandle',
33
+ 'useInsertionEffect',
34
+ 'useLayoutEffect',
35
+ 'useMemo',
36
+ 'useOptimistic',
37
+ 'useReducer',
38
+ 'useRef',
39
+ 'useState',
40
+ 'useTransition',
41
+ 'version',
42
+ 'ViewTransition',
43
+ 'addTransitionType',
44
+ ]);
45
+
46
+ // Framework client modules known not to pull preact/compat. Every other bare specifier (npm packages,
47
+ // next/dynamic, next/image, ...) disqualifies the lite tier.
48
+ const SAFE_BARE_SPECIFIERS = new Set([
49
+ 'next/link',
50
+ 'next/navigation',
51
+ 'preact',
52
+ 'preact/hooks',
53
+ 'preact/jsx-runtime',
54
+ 'react/jsx-runtime',
55
+ 'react/jsx-dev-runtime',
56
+ ]);
57
+
58
+ // Compat-only semantics preact core lacks: React's onChange/onDoubleClick synthetic mapping,
59
+ // default* form props, string refs, legacy class lifecycles - plus async component shapes (they
60
+ // suspend). Matching anywhere in a file (even a comment) just falls back to the full tier.
61
+ const UNSAFE_TOKENS =
62
+ /\bonChange\b|\bonDoubleClick\b|\bdefaultProps\b|\bgetSnapshotBeforeUpdate\b|\bcomponentWillReceiveProps\b|\bUNSAFE_\w+|\bref\s*=\s*["']|async\s+function\s+[A-Z]|(?:const|let|var)\s+[A-Z][\w$]*\s*=\s*async\b|export\s+default\s+async\b/;
63
+
64
+ // defaultValue/defaultChecked are native DOM properties preact core assigns correctly on
65
+ // input/textarea; only <select defaultValue> needs preact/compat's special handling.
66
+ function unsafeDefaultValue(source: string) {
67
+ return source.includes('defaultValue') && source.includes('<select');
68
+ }
69
+
70
+ const STATIC_IMPORT = /(?:^|\n)\s*(?:import|export)\s+([^;'"]*?)\s*from\s*(['"])([^'"]+)\2/g;
71
+ const BARE_IMPORT = /(?:^|\n)\s*import\s*(['"])([^'"]+)\1/g;
72
+ const DYNAMIC_IMPORT = /\bimport\(\s*(['"])([^'"]+)\1/g;
73
+
74
+ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
75
+ const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/;
76
+
77
+ export function clientSuspenseFree(seeds: Iterable<string>): boolean {
78
+ const queue: string[] = [];
79
+ const visited = new Set<string>();
80
+ for (const seed of seeds) {
81
+ if (!seed || visited.has(seed)) continue;
82
+ visited.add(seed);
83
+ queue.push(seed);
84
+ }
85
+ while (queue.length > 0) {
86
+ const file = queue.pop()!;
87
+ if (!SOURCE_EXTENSION.test(file) || !existsSync(file)) return false;
88
+ let source: string;
89
+ try {
90
+ source = readFileSync(file, 'utf8');
91
+ } catch {
92
+ return false;
93
+ }
94
+ if (UNSAFE_TOKENS.test(source) || unsafeDefaultValue(source)) return false;
95
+ for (const [clause, specifier] of importsOf(source)) {
96
+ if (specifier === 'react') {
97
+ if (!liteReactClause(clause)) return false;
98
+ continue;
99
+ }
100
+ if (SAFE_BARE_SPECIFIERS.has(specifier)) continue;
101
+ if (specifier.startsWith('.')) {
102
+ if (clause !== undefined && typeOnlyClause(clause)) continue;
103
+ const resolved = resolveRelative(path.dirname(file), specifier);
104
+ if (resolved === ASSET) continue;
105
+ if (!resolved) return false;
106
+ if (!visited.has(resolved)) {
107
+ visited.add(resolved);
108
+ queue.push(resolved);
109
+ }
110
+ continue;
111
+ }
112
+ // Unknown bare package or framework module that may pull preact/compat.
113
+ if (clause === undefined || !typeOnlyClause(clause)) return false;
114
+ }
115
+ }
116
+ return true;
117
+ }
118
+
119
+ function* importsOf(source: string): Generator<[clause: string | undefined, specifier: string]> {
120
+ for (const match of source.matchAll(STATIC_IMPORT)) yield [match[1], match[3] ?? ''];
121
+ for (const match of source.matchAll(BARE_IMPORT)) yield [undefined, match[2] ?? ''];
122
+ for (const match of source.matchAll(DYNAMIC_IMPORT)) yield [undefined, match[2] ?? ''];
123
+ }
124
+
125
+ function typeOnlyClause(clause: string) {
126
+ return /^type\s/.test(clause.trim());
127
+ }
128
+
129
+ function liteReactClause(clause: string | undefined): boolean {
130
+ if (clause === undefined) return true;
131
+ const trimmed = clause.trim();
132
+ if (typeOnlyClause(trimmed)) return true;
133
+ // Default or namespace import: the whole surface is reachable - full tier.
134
+ const braceStart = trimmed.indexOf('{');
135
+ if (braceStart !== 0) return false;
136
+ const braceEnd = trimmed.indexOf('}');
137
+ if (braceEnd === -1) return false;
138
+ for (const entry of trimmed.slice(braceStart + 1, braceEnd).split(',')) {
139
+ const name = entry.trim();
140
+ if (!name || name.startsWith('type ')) continue;
141
+ const imported = (name.split(/\s+as\s+/)[0] ?? '').trim();
142
+ if (!LITE_REACT_NAMES.has(imported)) return false;
143
+ }
144
+ return true;
145
+ }
146
+
147
+ const ASSET = Symbol('pnext.compat-surface.asset');
148
+
149
+ function resolveRelative(dir: string, specifier: string): string | typeof ASSET | undefined {
150
+ let base = path.resolve(dir, specifier);
151
+ const direct = fileAt(base);
152
+ if (direct) return direct;
153
+ // NodeNext-style `./x.js` pointing at `./x.ts(x)`.
154
+ if (/\.(?:js|jsx|mjs|cjs)$/.test(base)) base = base.replace(/\.[^.]+$/, '');
155
+ for (const extension of SOURCE_EXTENSIONS) {
156
+ const candidate = fileAt(base + extension);
157
+ if (candidate) return candidate;
158
+ }
159
+ for (const extension of SOURCE_EXTENSIONS) {
160
+ const candidate = fileAt(path.join(base, `index${extension}`));
161
+ if (candidate) return candidate;
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ function fileAt(candidate: string): string | typeof ASSET | undefined {
167
+ if (!existsSync(candidate)) return undefined;
168
+ try {
169
+ if (!statSync(candidate).isFile()) return undefined;
170
+ } catch {
171
+ return undefined;
172
+ }
173
+ // Styles/assets never carry react surface.
174
+ return SOURCE_EXTENSION.test(candidate) ? candidate : ASSET;
175
+ }