@tanstack/start-plugin-core 1.162.6 → 1.162.8

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.
@@ -26,6 +26,7 @@ import {
26
26
  MOCK_MODULE_ID,
27
27
  MOCK_RUNTIME_PREFIX,
28
28
  RESOLVED_MARKER_PREFIX,
29
+ RESOLVED_MOCK_BUILD_PREFIX,
29
30
  RESOLVED_MOCK_EDGE_PREFIX,
30
31
  RESOLVED_MOCK_MODULE_ID,
31
32
  RESOLVED_MOCK_RUNTIME_PREFIX,
@@ -122,7 +123,9 @@ interface PluginConfig {
122
123
  markerSpecifiers: { serverOnly: Set<string>; clientOnly: Set<string> }
123
124
  envTypeMap: Map<string, 'client' | 'server'>
124
125
 
125
- onViolation?: (info: ViolationInfo) => boolean | void
126
+ onViolation?: (
127
+ info: ViolationInfo,
128
+ ) => boolean | void | Promise<boolean | void>
126
129
  }
127
130
 
128
131
  /**
@@ -192,6 +195,13 @@ interface EnvState {
192
195
  * determine whether the importer is still reachable from an entry point.
193
196
  */
194
197
  pendingViolations: Map<string, Array<PendingViolation>>
198
+
199
+ /**
200
+ * Violations deferred in build mode (both mock and error). Each gets a
201
+ * unique mock module ID so we can check which ones survived tree-shaking
202
+ * in `generateBundle`.
203
+ */
204
+ deferredBuildViolations: Array<DeferredBuildViolation>
195
205
  }
196
206
 
197
207
  interface PendingViolation {
@@ -200,6 +210,12 @@ interface PendingViolation {
200
210
  mockReturnValue: string
201
211
  }
202
212
 
213
+ interface DeferredBuildViolation {
214
+ info: ViolationInfo
215
+ /** Unique mock module ID assigned to this violation. */
216
+ mockModuleId: string
217
+ }
218
+
203
219
  /**
204
220
  * Intentionally cross-env shared mutable state.
205
221
  *
@@ -569,6 +585,7 @@ export function importProtectionPlugin(
569
585
  postTransformImports: new Map(),
570
586
  serverFnLookupModules: new Set(),
571
587
  pendingViolations: new Map(),
588
+ deferredBuildViolations: [],
572
589
  }
573
590
  envStates.set(envName, envState)
574
591
  }
@@ -772,7 +789,7 @@ export function importProtectionPlugin(
772
789
  }
773
790
 
774
791
  if (config.onViolation) {
775
- const result = config.onViolation(pv.info)
792
+ const result = await config.onViolation(pv.info)
776
793
  if (result === false) continue
777
794
  }
778
795
  warnFn(formatViolation(pv.info, config.root))
@@ -812,12 +829,20 @@ export function importProtectionPlugin(
812
829
  })
813
830
  }
814
831
 
815
- function handleViolation(
832
+ /** Counter for generating unique per-violation mock module IDs in build mode. */
833
+ let buildViolationCounter = 0
834
+
835
+ type HandleViolationResult =
836
+ | { id: string; syntheticNamedExports: boolean }
837
+ | string
838
+ | undefined
839
+
840
+ async function handleViolation(
816
841
  ctx: ViolationReporter,
817
842
  env: EnvState,
818
843
  info: ViolationInfo,
819
844
  violationOpts?: { silent?: boolean },
820
- ): { id: string; syntheticNamedExports: boolean } | string | undefined {
845
+ ): Promise<HandleViolationResult> {
821
846
  const key = dedupeKey(
822
847
  info.type,
823
848
  info.importer,
@@ -827,24 +852,35 @@ export function importProtectionPlugin(
827
852
 
828
853
  if (!violationOpts?.silent) {
829
854
  if (config.onViolation) {
830
- const result = config.onViolation(info)
855
+ const result = await config.onViolation(info)
831
856
  if (result === false) {
832
857
  return undefined
833
858
  }
834
859
  }
835
860
 
836
- const seen = hasSeen(env, key)
837
-
838
861
  if (config.effectiveBehavior === 'error') {
839
- if (!seen) ctx.error(formatViolation(info, config.root))
840
- return undefined
862
+ // Dev+error: throw immediately.
863
+ // Always throw on error — do NOT deduplicate via hasSeen().
864
+ // Rollup may resolve the same specifier multiple times (e.g.
865
+ // commonjs--resolver's nested this.resolve() fires before
866
+ // getResolveStaticDependencyPromises). If we record the key
867
+ // on the first (nested) throw, the second (real) resolve
868
+ // silently returns undefined and the build succeeds — which
869
+ // is the bug this fixes.
870
+ //
871
+ // Build mode never reaches here — all build violations are
872
+ // deferred via shouldDefer and handled silently.
873
+
874
+ return ctx.error(formatViolation(info, config.root))
841
875
  }
842
876
 
877
+ const seen = hasSeen(env, key)
878
+
843
879
  if (!seen) {
844
880
  ctx.warn(formatViolation(info, config.root))
845
881
  }
846
882
  } else {
847
- if (config.effectiveBehavior === 'error') {
883
+ if (config.effectiveBehavior === 'error' && config.command !== 'build') {
848
884
  return undefined
849
885
  }
850
886
  }
@@ -868,17 +904,23 @@ export function importProtectionPlugin(
868
904
  )
869
905
  }
870
906
 
871
- // Build: Rollup uses syntheticNamedExports
872
- return { id: RESOLVED_MOCK_MODULE_ID, syntheticNamedExports: true }
907
+ // Build: unique per-violation mock IDs so generateBundle can check
908
+ // which violations survived tree-shaking (both mock and error mode).
909
+ const mockId = `${RESOLVED_MOCK_BUILD_PREFIX}${buildViolationCounter++}`
910
+ return { id: mockId, syntheticNamedExports: true }
873
911
  }
874
912
 
875
913
  /**
876
914
  * Unified violation dispatch: either defers or reports immediately.
877
915
  *
878
916
  * When `shouldDefer` is true, calls `handleViolation` silently to obtain
879
- * the mock module ID, stores the violation as pending, and triggers
880
- * `processPendingViolations`. Otherwise reports (or silences for
881
- * pre-transform resolves) immediately.
917
+ * the mock module ID, then stores the violation for later verification:
918
+ * - Dev mock mode: defers to `pendingViolations` for graph-reachability
919
+ * checking via `processPendingViolations`.
920
+ * - Build mode (mock + error): defers to `deferredBuildViolations` for
921
+ * tree-shaking verification in `generateBundle`.
922
+ *
923
+ * Otherwise reports (or silences for pre-transform resolves) immediately.
882
924
  *
883
925
  * Returns the mock module ID / resolve result from `handleViolation`.
884
926
  */
@@ -889,14 +931,24 @@ export function importProtectionPlugin(
889
931
  info: ViolationInfo,
890
932
  shouldDefer: boolean,
891
933
  isPreTransformResolve: boolean,
892
- ): Promise<ReturnType<typeof handleViolation>> {
934
+ ): Promise<HandleViolationResult> {
893
935
  if (shouldDefer) {
894
- const result = handleViolation(ctx, env, info, { silent: true })
895
- deferViolation(env, importerFile, info, result)
896
- await processPendingViolations(env, ctx.warn.bind(ctx))
936
+ const result = await handleViolation(ctx, env, info, { silent: true })
937
+
938
+ if (config.command === 'build') {
939
+ // Build mode: store for generateBundle tree-shaking check.
940
+ // The unique mock ID is inside `result`.
941
+ const mockId = typeof result === 'string' ? result : (result?.id ?? '')
942
+ env.deferredBuildViolations.push({ info, mockModuleId: mockId })
943
+ } else {
944
+ // Dev mock: store for graph-reachability check.
945
+ deferViolation(env, importerFile, info, result)
946
+ await processPendingViolations(env, ctx.warn.bind(ctx))
947
+ }
948
+
897
949
  return result
898
950
  }
899
- return handleViolation(ctx, env, info, {
951
+ return await handleViolation(ctx, env, info, {
900
952
  silent: isPreTransformResolve,
901
953
  })
902
954
  }
@@ -1027,6 +1079,8 @@ export function importProtectionPlugin(
1027
1079
  envState.transformResultKeysByFile.clear()
1028
1080
  envState.postTransformImports.clear()
1029
1081
  envState.serverFnLookupModules.clear()
1082
+ envState.pendingViolations.clear()
1083
+ envState.deferredBuildViolations.length = 0
1030
1084
  envState.graph.clear()
1031
1085
  envState.deniedSources.clear()
1032
1086
  envState.deniedEdges.clear()
@@ -1156,10 +1210,13 @@ export function importProtectionPlugin(
1156
1210
 
1157
1211
  // Dev mock mode: defer violations until post-transform data is
1158
1212
  // available, then confirm/discard via graph reachability.
1213
+ // Build mode (both mock and error): defer violations until
1214
+ // generateBundle so tree-shaking can eliminate false positives.
1159
1215
  const isDevMock =
1160
1216
  config.command === 'serve' && config.effectiveBehavior === 'mock'
1217
+ const isBuild = config.command === 'build'
1161
1218
 
1162
- const shouldDefer = isDevMock && !isPreTransformResolve
1219
+ const shouldDefer = (isDevMock && !isPreTransformResolve) || isBuild
1163
1220
 
1164
1221
  // Check if this is a marker import
1165
1222
  const markerKind = config.markerSpecifiers.serverOnly.has(source)
@@ -1198,7 +1255,7 @@ export function importProtectionPlugin(
1198
1255
  : `Module "${getRelativePath(normalizedImporter)}" is marked client-only but is imported in the server environment`,
1199
1256
  },
1200
1257
  )
1201
- await reportOrDeferViolation(
1258
+ const markerResult = await reportOrDeferViolation(
1202
1259
  this,
1203
1260
  env,
1204
1261
  normalizedImporter,
@@ -1206,6 +1263,16 @@ export function importProtectionPlugin(
1206
1263
  shouldDefer,
1207
1264
  isPreTransformResolve,
1208
1265
  )
1266
+
1267
+ // In build mode, if the violation was deferred, return the unique
1268
+ // build mock ID instead of the marker module. This lets
1269
+ // generateBundle check whether the importer (and thus its marker
1270
+ // import) survived tree-shaking. The mock is side-effect-free just
1271
+ // like the marker module, and the bare import has no bindings, so
1272
+ // replacing it is transparent.
1273
+ if (isBuild && markerResult != null) {
1274
+ return markerResult
1275
+ }
1209
1276
  }
1210
1277
 
1211
1278
  return markerKind === 'server'
@@ -1338,6 +1405,7 @@ export function importProtectionPlugin(
1338
1405
  id: new RegExp(
1339
1406
  [
1340
1407
  RESOLVED_MOCK_MODULE_ID,
1408
+ RESOLVED_MOCK_BUILD_PREFIX,
1341
1409
  RESOLVED_MARKER_PREFIX,
1342
1410
  RESOLVED_MOCK_EDGE_PREFIX,
1343
1411
  RESOLVED_MOCK_RUNTIME_PREFIX,
@@ -1360,6 +1428,11 @@ export function importProtectionPlugin(
1360
1428
  return loadSilentMockModule()
1361
1429
  }
1362
1430
 
1431
+ // Per-violation build mock modules — same silent mock code
1432
+ if (id.startsWith(RESOLVED_MOCK_BUILD_PREFIX)) {
1433
+ return loadSilentMockModule()
1434
+ }
1435
+
1363
1436
  if (id.startsWith(RESOLVED_MOCK_EDGE_PREFIX)) {
1364
1437
  return loadMockEdgeModule(
1365
1438
  id.slice(RESOLVED_MOCK_EDGE_PREFIX.length),
@@ -1379,6 +1452,58 @@ export function importProtectionPlugin(
1379
1452
  return undefined
1380
1453
  },
1381
1454
  },
1455
+
1456
+ async generateBundle(_options, bundle) {
1457
+ const envName = this.environment.name
1458
+ const env = envStates.get(envName)
1459
+ if (!env || env.deferredBuildViolations.length === 0) return
1460
+
1461
+ // Collect all module IDs that survived tree-shaking in this bundle.
1462
+ const survivingModules = new Set<string>()
1463
+ for (const chunk of Object.values(bundle)) {
1464
+ if (chunk.type === 'chunk') {
1465
+ for (const moduleId of Object.keys(chunk.modules)) {
1466
+ survivingModules.add(moduleId)
1467
+ }
1468
+ }
1469
+ }
1470
+
1471
+ // Check each deferred violation: if its unique mock module survived
1472
+ // in the bundle, the import was NOT tree-shaken — real leak.
1473
+ const realViolations: Array<ViolationInfo> = []
1474
+ for (const { info, mockModuleId } of env.deferredBuildViolations) {
1475
+ if (!survivingModules.has(mockModuleId)) continue
1476
+
1477
+ if (config.onViolation) {
1478
+ const result = await config.onViolation(info)
1479
+ if (result === false) continue
1480
+ }
1481
+
1482
+ realViolations.push(info)
1483
+ }
1484
+
1485
+ if (realViolations.length === 0) return
1486
+
1487
+ if (config.effectiveBehavior === 'error') {
1488
+ // Error mode: fail the build on the first real violation.
1489
+ this.error(formatViolation(realViolations[0]!, config.root))
1490
+ } else {
1491
+ // Mock mode: warn for each surviving violation.
1492
+ const seen = new Set<string>()
1493
+ for (const info of realViolations) {
1494
+ const key = dedupeKey(
1495
+ info.type,
1496
+ info.importer,
1497
+ info.specifier,
1498
+ info.resolved,
1499
+ )
1500
+ if (!seen.has(key)) {
1501
+ seen.add(key)
1502
+ this.warn(formatViolation(info, config.root))
1503
+ }
1504
+ }
1505
+ }
1506
+ },
1382
1507
  },
1383
1508
  {
1384
1509
  // Captures transformed code + composed sourcemap for location mapping.
@@ -8,6 +8,14 @@ import type { ViolationInfo } from './trace'
8
8
  export const MOCK_MODULE_ID = 'tanstack-start-import-protection:mock'
9
9
  export const RESOLVED_MOCK_MODULE_ID = resolveViteId(MOCK_MODULE_ID)
10
10
 
11
+ /**
12
+ * Per-violation mock prefix used in build+error mode.
13
+ * Each deferred violation gets a unique ID so we can check which ones
14
+ * survived tree-shaking in `generateBundle`.
15
+ */
16
+ export const MOCK_BUILD_PREFIX = 'tanstack-start-import-protection:mock:build:'
17
+ export const RESOLVED_MOCK_BUILD_PREFIX = resolveViteId(MOCK_BUILD_PREFIX)
18
+
11
19
  export const MOCK_EDGE_PREFIX = 'tanstack-start-import-protection:mock-edge:'
12
20
  export const RESOLVED_MOCK_EDGE_PREFIX = resolveViteId(MOCK_EDGE_PREFIX)
13
21
 
package/src/schema.ts CHANGED
@@ -42,7 +42,13 @@ const importProtectionOptionsSchema = z
42
42
  onViolation: z
43
43
  .function()
44
44
  .args(z.any())
45
- .returns(z.union([z.boolean(), z.void()]))
45
+ .returns(
46
+ z.union([
47
+ z.boolean(),
48
+ z.void(),
49
+ z.promise(z.union([z.boolean(), z.void()])),
50
+ ]),
51
+ )
46
52
  .optional(),
47
53
  include: z.array(patternSchema).optional(),
48
54
  exclude: z.array(patternSchema).optional(),