@wular/pnext 0.0.1 → 0.0.3
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/README.md +68 -103
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +87 -29
- package/src/api/router/runtime.ts +10 -2
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +39 -5
- package/src/cli/create.ts +146 -0
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +28 -4
- package/src/cli/migrate/index.ts +96 -0
- package/src/cli/migrate/package-json.ts +183 -0
- package/src/cli/migrate/report.ts +80 -0
- package/src/cli/migrate/scan.ts +135 -0
- package/src/cli/migrate/spinner.ts +20 -0
- package/src/cli/migrate/tsconfig.ts +77 -0
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/index.ts +1 -1
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/compat/tsconfig-defaults.ts +6 -3
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +35 -2
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +5 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +14 -2
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- package/src/utils/native-require.ts +28 -0
package/src/client/build.ts
CHANGED
|
@@ -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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
150
|
+
function routeUsesActions(route: RouteManifestEntry, appActions: boolean) {
|
|
151
|
+
if (!appActions) return false;
|
|
150
152
|
return (
|
|
151
|
-
|
|
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
|
-
|
|
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>();
|
package/src/client/chunk-fold.ts
CHANGED
|
@@ -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
|
+
}
|