@ttsc/unplugin 0.19.1 → 0.19.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/src/core/index.ts CHANGED
@@ -4,8 +4,11 @@ import { createUnplugin } from "unplugin";
4
4
  import type { TtscUnpluginOptions } from "./options";
5
5
  import { resolveOptions } from "./options";
6
6
  import {
7
+ collectExternalInputHashes,
8
+ collectProjectInputHashes,
7
9
  createTtscTransformCache,
8
10
  isDeclarationFile,
11
+ isProjectWalkPath,
9
12
  stripQuery,
10
13
  transformTtsc,
11
14
  } from "./transform";
@@ -68,11 +71,24 @@ const unpluginFactory: UnpluginFactory<
68
71
  return undefined;
69
72
  }
70
73
  return transformTtsc(file, source, options, aliases, transformCache, {
71
- // Register plugin-reported dependencies (the transform envelope's
72
- // `dependencies` lists) so type-only inputs invalidate this module
73
- // in watch mode; bundlers erase type-only imports from their own
74
- // module graph and would otherwise serve stale generated code.
74
+ // Register the derived watch inputs (plugin-reported `dependencies`
75
+ // unioned with the host-owned reference graph) so type-only inputs
76
+ // invalidate this module in watch mode and persistent caches;
77
+ // bundlers erase type-only imports from their own module graph and
78
+ // would otherwise serve stale generated code.
75
79
  addWatchFile: (watched) => this.addWatchFile(watched),
80
+ // A module the plugin declared volatile depends on non-file inputs,
81
+ // which no file-dependency snapshot can represent; mark it
82
+ // uncacheable where the bundler exposes that control.
83
+ markVolatile: () => {
84
+ const native = this.getNativeBuildContext?.();
85
+ if (
86
+ native?.framework === "webpack" ||
87
+ native?.framework === "rspack"
88
+ ) {
89
+ native.loaderContext?.cacheable?.(false);
90
+ }
91
+ },
76
92
  });
77
93
  },
78
94
  };
@@ -86,7 +102,15 @@ export type {
86
102
  TtscUnpluginOptions,
87
103
  } from "./options";
88
104
  export type { TtscTransformHooks } from "./transform";
89
- export { createTtscTransformCache, resolveOptions, transformTtsc, unplugin };
105
+ export {
106
+ collectExternalInputHashes,
107
+ collectProjectInputHashes,
108
+ createTtscTransformCache,
109
+ isProjectWalkPath,
110
+ resolveOptions,
111
+ transformTtsc,
112
+ unplugin,
113
+ };
90
114
 
91
115
  export default unplugin;
92
116
 
@@ -51,6 +51,20 @@ export interface TtscTransformAlias {
51
51
  * miss on every module.
52
52
  */
53
53
  export interface TtscCachedProjectTransform {
54
+ /**
55
+ * SHA-256 hash of every input the compiler reported outside the project walk
56
+ * (keyed by absolute path), captured at the time of the transform.
57
+ *
58
+ * The project walk cannot see files outside the project root or under ignored
59
+ * directories (`node_modules` declarations, monorepo sibling sources,
60
+ * out-of-root tsconfig `extends` ancestry), yet the host-owned reference
61
+ * graph proves they are transform inputs. Long-lived hosts that never clear
62
+ * the cache between builds (Metro workers, the Turbopack loader, Bun) would
63
+ * otherwise replay a project transform computed against a stale out-of-walk
64
+ * input for the whole process lifetime; per-build hosts clear the cache on
65
+ * `buildStart` and never replay across edits.
66
+ */
67
+ externalInputHashes?: Record<string, string>;
54
68
  /**
55
69
  * SHA-256 hash of each project-relative input path at the time of the
56
70
  * transform.
@@ -60,6 +74,15 @@ export interface TtscCachedProjectTransform {
60
74
  projectRoot: string;
61
75
  /** Raw compiler output returned by {@link TtscCompiler.transform}. */
62
76
  result: ITtscCompilerTransformation;
77
+ /**
78
+ * Absolute path of the generated temp-dir tsconfig this compile ran against,
79
+ * when an alias/compiler-options overlay required one. The compiler reports
80
+ * it in the envelope's `graph.configs` chain, but it is disposed right after
81
+ * the compile, so registering it as a watch input would invalidate every
82
+ * bundler cache snapshot on the next build; watch derivation must skip
83
+ * exactly this path.
84
+ */
85
+ temporaryTsconfig?: string;
63
86
  }
64
87
 
65
88
  /**
@@ -85,12 +108,25 @@ export function createTtscTransformCache(): TtscTransformCache {
85
108
  */
86
109
  export interface TtscTransformHooks {
87
110
  /**
88
- * Invoked once per absolute dependency path the plugin reported for the
89
- * transformed file (`dependencies` in the transform envelope). Adapters
90
- * forward this to the bundler's `addWatchFile` so type-only inputs
91
- * participate in HMR invalidation.
111
+ * Invoked once per absolute watch-input path derived for the transformed file
112
+ * `F`: the plugin-reported `dependencies[F]` list unioned with the host-owned
113
+ * reference graph's contribution the reachability closure of `graph.edges`
114
+ * from `F`, the `graph.globals` files, and the `graph.configs` chain — or,
115
+ * for a file the envelope declared `dependenciesComplete`, only
116
+ * `dependencies[F]` and the universal `graph.configs` chain. Adapters forward
117
+ * this to the bundler's `addWatchFile` so type-only inputs participate in
118
+ * watch-mode and persistent-cache invalidation. See {@link selectWatchInputs}
119
+ * for the exact derivation.
92
120
  */
93
121
  addWatchFile?: (file: string) => void;
122
+ /**
123
+ * Invoked when the plugin declared the transformed file volatile (the
124
+ * envelope's `volatile` list): its output depends on non-file inputs that no
125
+ * file-dependency snapshot can represent. Adapters should mark the module
126
+ * uncacheable where the bundler exposes that control (e.g. a webpack loader
127
+ * context's `cacheable(false)`).
128
+ */
129
+ markVolatile?: () => void;
94
130
  }
95
131
 
96
132
  /**
@@ -150,7 +186,17 @@ export async function transformTtsc(
150
186
  // A rejected in-flight generation must not stay cached: evict it (only if
151
187
  // it is still the current entry) so a later call re-runs the transform.
152
188
  const cached = await awaitOrEvict(cache, key, transformed);
153
- if (matchesCachedSource(cached, file, source)) {
189
+ if (
190
+ // A file the plugin declared volatile must never be served from the
191
+ // cache: its output depends on non-file inputs, so the input-hash
192
+ // snapshot cannot prove freshness. Fall through to a fresh transform.
193
+ !isVolatileFile({
194
+ file,
195
+ projectRoot: cached.projectRoot,
196
+ result: cached.result,
197
+ }) &&
198
+ matchesCachedSource(cached, file, source)
199
+ ) {
154
200
  reportSuccessDiagnostics(cached.result);
155
201
  // A resolved `"exception"` / `"failure"` envelope makes this throw; that
156
202
  // is a failed generation too, so evict before surfacing it.
@@ -159,10 +205,11 @@ export async function transformTtsc(
159
205
  projectRoot: cached.projectRoot,
160
206
  result: cached.result,
161
207
  });
162
- notifyFileDependencies(hooks, {
208
+ notifyWatchInputs(hooks, {
163
209
  file,
164
210
  projectRoot: cached.projectRoot,
165
211
  result: cached.result,
212
+ temporaryTsconfig: cached.temporaryTsconfig,
166
213
  });
167
214
  return createTransformResult(source, code);
168
215
  }
@@ -182,14 +229,21 @@ export async function transformTtsc(
182
229
  cache?.set(key, transformed);
183
230
  }
184
231
  const generation = transformed;
185
- const { projectRoot, result } = await awaitOrEvict(cache, key, generation);
232
+ const { projectRoot, result, temporaryTsconfig } = await awaitOrEvict(
233
+ cache,
234
+ key,
235
+ generation,
236
+ );
186
237
  reportSuccessDiagnostics(result);
187
238
  const code = selectOrEvict(cache, key, generation, {
188
239
  file,
189
240
  projectRoot,
190
241
  result,
191
242
  });
192
- notifyFileDependencies(hooks, { file, projectRoot, result });
243
+ notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
244
+ if (isVolatileFile({ file, projectRoot, result })) {
245
+ hooks?.markVolatile?.();
246
+ }
193
247
  return createTransformResult(source, code);
194
248
  }
195
249
 
@@ -256,29 +310,232 @@ function evictGeneration(
256
310
  }
257
311
 
258
312
  /**
259
- * Forward the plugin-reported dependency list for `file` to the adapter's
260
- * `addWatchFile` hook.
313
+ * Forward every derived watch input for `file` to the adapter's `addWatchFile`
314
+ * hook: the plugin-reported `dependencies[file]` list unioned with the
315
+ * host-owned reference graph's contribution (`reach(edges, file)`, `globals`,
316
+ * `configs`).
261
317
  *
262
- * The transform envelope's `dependencies` keys mirror the `typescript` keys
263
- * (project-relative); values may be project-relative or absolute. Every path is
264
- * absolutized against the project root and deduplicated, and the file itself is
265
- * dropped; the bundler already watches the module it transforms.
318
+ * Envelope keys mirror the `typescript` keys (project-relative); values may be
319
+ * project-relative or absolute. Every path is absolutized against the project
320
+ * root and deduplicated; the file itself is dropped (the bundler already
321
+ * watches the module it transforms), and so is the disposed temp-dir tsconfig
322
+ * (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
266
323
  */
267
- function notifyFileDependencies(
324
+ function notifyWatchInputs(
268
325
  hooks: TtscTransformHooks | undefined,
269
326
  props: {
270
327
  file: string;
271
328
  projectRoot: string;
272
329
  result: ITtscCompilerTransformation;
330
+ temporaryTsconfig?: string;
273
331
  },
274
332
  ): void {
275
333
  const addWatchFile = hooks?.addWatchFile;
276
334
  if (addWatchFile === undefined) {
277
335
  return;
278
336
  }
279
- for (const dependency of selectFileDependencies(props)) {
280
- addWatchFile(dependency);
337
+ for (const input of selectWatchInputs(props)) {
338
+ addWatchFile(input);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Derive the absolute, deduplicated watch-input list for a single file.
344
+ *
345
+ * By default the derivation is a union: `dependencies[file] ∪ reach(edges,
346
+ * file) ∪ globals ∪ configs`. The plugin-reported list can only widen the
347
+ * host-owned language-semantic bound, never narrow it.
348
+ *
349
+ * An envelope that lists `file` in `dependenciesComplete` narrows it to
350
+ * `dependencies[file] ∪ configs`: the plugin declared its reported list the
351
+ * complete input set for that file, which transfers responsibility for the
352
+ * dropped `reach(edges, file) ∪ globals` bound to the plugin (see the protocol
353
+ * page's completeness contract). The config chain stays universal regardless,
354
+ * because compiler options reach generated code through the host rather than
355
+ * through any file a plugin could consult. A file the plugin also declared
356
+ * volatile keeps the baseline: the two declarations contradict, so the
357
+ * conservative one wins.
358
+ *
359
+ * Returns an empty list on exceptions.
360
+ */
361
+ function selectWatchInputs(props: {
362
+ file: string;
363
+ projectRoot: string;
364
+ result: ITtscCompilerTransformation;
365
+ temporaryTsconfig?: string;
366
+ }): string[] {
367
+ const output: string[] = [];
368
+ const seen = new Set<string>();
369
+ const excluded = new Set(
370
+ props.temporaryTsconfig === undefined
371
+ ? [props.file]
372
+ : [props.file, path.resolve(props.temporaryTsconfig)],
373
+ );
374
+ for (const absolute of [
375
+ ...selectFileDependencies(props),
376
+ ...selectGraphInputs({
377
+ ...props,
378
+ complete: declaresCompleteDependencies(props) && !isVolatileFile(props),
379
+ }),
380
+ ]) {
381
+ if (excluded.has(absolute) || seen.has(absolute)) {
382
+ continue;
383
+ }
384
+ seen.add(absolute);
385
+ output.push(absolute);
281
386
  }
387
+ return output;
388
+ }
389
+
390
+ /**
391
+ * Flatten the host-owned reference graph for one file into absolute paths.
392
+ *
393
+ * The full contribution is the reachability closure of `edges` starting at the
394
+ * file, plus every global-scope file and the config chain. Flattening direct
395
+ * edges into a per-file list happens here — at the adapter boundary — because
396
+ * bundler `fileDependencies` snapshots are flat; the protocol itself carries
397
+ * only direct edges.
398
+ *
399
+ * `complete` drops the reach and globals halves, keeping only the universal
400
+ * config chain: the caller established that the plugin declared its own
401
+ * `dependencies[file]` list the complete replacement for them. Returns an empty
402
+ * list on exceptions or without a graph.
403
+ */
404
+ function selectGraphInputs(props: {
405
+ complete: boolean;
406
+ file: string;
407
+ projectRoot: string;
408
+ result: ITtscCompilerTransformation;
409
+ }): string[] {
410
+ if (props.result.type === "exception") {
411
+ return [];
412
+ }
413
+ const graph = props.result.graph;
414
+ if (graph === undefined) {
415
+ return [];
416
+ }
417
+ const output: string[] = [];
418
+ if (!props.complete) {
419
+ output.push(...selectReachableEdges(props.projectRoot, props.file, graph));
420
+ output.push(...selectListedFiles(props.projectRoot, graph.globals));
421
+ }
422
+ output.push(...selectListedFiles(props.projectRoot, graph.configs));
423
+ return output;
424
+ }
425
+
426
+ /**
427
+ * Walk the reachability closure of the graph's direct `edges` from `file`,
428
+ * returning the absolute path of every file reached (the starting file itself
429
+ * excluded, even when a cycle points back at it).
430
+ */
431
+ function selectReachableEdges(
432
+ projectRoot: string,
433
+ file: string,
434
+ graph: ITtscCompilerTransformation.IReferenceGraph,
435
+ ): string[] {
436
+ const edges = new Map<string, string[]>();
437
+ for (const [source, targets] of Object.entries(graph.edges ?? {})) {
438
+ if (!Array.isArray(targets)) {
439
+ continue;
440
+ }
441
+ edges.set(
442
+ path.resolve(projectRoot, source),
443
+ targets
444
+ .filter(
445
+ (target): target is string =>
446
+ typeof target === "string" && target.length !== 0,
447
+ )
448
+ .map((target) => path.resolve(projectRoot, target)),
449
+ );
450
+ }
451
+ const output: string[] = [];
452
+ const visited = new Set<string>([file]);
453
+ const queue = [file];
454
+ while (queue.length !== 0) {
455
+ const current = queue.pop()!;
456
+ for (const target of edges.get(current) ?? []) {
457
+ if (visited.has(target)) {
458
+ continue;
459
+ }
460
+ visited.add(target);
461
+ queue.push(target);
462
+ output.push(target);
463
+ }
464
+ }
465
+ return output;
466
+ }
467
+
468
+ /**
469
+ * Absolutize one graph string list (`globals`, `configs`), skipping members a
470
+ * malformed envelope section may carry. Duplicates survive; the caller
471
+ * deduplicates the merged list.
472
+ */
473
+ function selectListedFiles(projectRoot: string, listed: unknown): string[] {
474
+ if (!Array.isArray(listed)) {
475
+ return [];
476
+ }
477
+ const output: string[] = [];
478
+ for (const entry of listed) {
479
+ if (typeof entry !== "string" || entry.length === 0) {
480
+ continue;
481
+ }
482
+ output.push(path.resolve(projectRoot, entry));
483
+ }
484
+ return output;
485
+ }
486
+
487
+ /**
488
+ * Report whether the plugin declared `file` volatile: its output depends on
489
+ * non-file inputs (environment, time, network), so neither the project
490
+ * transform cache nor a bundler's persistent cache may replay it.
491
+ */
492
+ function isVolatileFile(props: {
493
+ file: string;
494
+ projectRoot: string;
495
+ result: ITtscCompilerTransformation;
496
+ }): boolean {
497
+ if (props.result.type === "exception") {
498
+ return false;
499
+ }
500
+ return declaresFile(props.result.volatile, props);
501
+ }
502
+
503
+ /**
504
+ * Report whether the envelope declared `dependencies[file]` complete, i.e. the
505
+ * plugin took responsibility for that file's whole input set beyond the file
506
+ * itself and the universal config chain. Callers must still keep the baseline
507
+ * for a file the same envelope declared volatile.
508
+ */
509
+ function declaresCompleteDependencies(props: {
510
+ file: string;
511
+ projectRoot: string;
512
+ result: ITtscCompilerTransformation;
513
+ }): boolean {
514
+ if (props.result.type === "exception") {
515
+ return false;
516
+ }
517
+ return declaresFile(props.result.dependenciesComplete, props);
518
+ }
519
+
520
+ /**
521
+ * Report whether one of the envelope's transformed-file lists (`volatile`,
522
+ * `dependenciesComplete`) names `file`. Members are keyed like `typescript`, so
523
+ * a project-relative and an absolute spelling of the same file both match; a
524
+ * malformed member is ignored rather than fatal.
525
+ */
526
+ function declaresFile(
527
+ listed: unknown,
528
+ props: { file: string; projectRoot: string },
529
+ ): boolean {
530
+ if (!Array.isArray(listed)) {
531
+ return false;
532
+ }
533
+ return listed.some(
534
+ (entry) =>
535
+ typeof entry === "string" &&
536
+ entry.length !== 0 &&
537
+ path.resolve(props.projectRoot, entry) === props.file,
538
+ );
282
539
  }
283
540
 
284
541
  /**
@@ -384,9 +641,11 @@ export function createTransformResult(
384
641
  * in-memory source, then compares the snapshot against the one captured when
385
642
  * the result was produced. Any input under the project root changing (the
386
643
  * module itself or a sibling the plugin reads) invalidates the entry and forces
387
- * a re-transform. Out-of-walk inputs a plugin pulls in (`node_modules`
388
- * declarations, sibling-package sources) are not seen here; adapters invalidate
389
- * on those through the reported `dependencies` → `addWatchFile` → the bundler's
644
+ * a re-transform. Out-of-walk inputs the compiler reported (`node_modules`
645
+ * declarations, sibling-package sources, out-of-root config ancestry) are
646
+ * validated through {@link TtscCachedProjectTransform.externalInputHashes};
647
+ * adapters additionally register them as derived watch inputs (the host-owned
648
+ * `graph` union the reported `dependencies`) → `addWatchFile` → the bundler's
390
649
  * next `buildStart` cache clear.
391
650
  *
392
651
  * Both this snapshot and {@link collectInputHashes} draw their keys from the
@@ -405,7 +664,22 @@ function matchesCachedSource(
405
664
  const currentKey = toProjectKey(cached.projectRoot, file);
406
665
  const currentHashes = collectProjectInputHashes(cached.projectRoot);
407
666
  currentHashes[currentKey] = hashText(source);
408
- return sameHashes(cached.inputHashes, currentHashes);
667
+ if (!sameHashes(cached.inputHashes, currentHashes)) {
668
+ return false;
669
+ }
670
+ // Re-hash the out-of-walk inputs the compiler reported for this generation
671
+ // over exactly the recorded key universe, so an edit to a `node_modules`
672
+ // declaration or a monorepo sibling source invalidates the entry even in a
673
+ // host that never clears the cache between builds. A new out-of-walk input
674
+ // cannot appear without some recorded input changing first: a new reference
675
+ // edge requires editing an in-walk source, and a new global or config file
676
+ // requires a tsconfig or package manifest change, both of which the project
677
+ // walk above already detects.
678
+ const externalHashes = cached.externalInputHashes ?? {};
679
+ return sameHashes(
680
+ externalHashes,
681
+ collectExternalInputHashes(Object.keys(externalHashes)),
682
+ );
409
683
  }
410
684
 
411
685
  /**
@@ -434,7 +708,13 @@ function collectInputHashes(props: {
434
708
  return hashes;
435
709
  }
436
710
 
437
- function collectProjectInputHashes(
711
+ /**
712
+ * Hash every input file under `projectRoot` (the same walk universe
713
+ * {@link matchesCachedSource} validates against), keyed by project-relative
714
+ * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
715
+ * can fold the identical input universe into their own cache fingerprints.
716
+ */
717
+ export function collectProjectInputHashes(
438
718
  projectRoot: string,
439
719
  ): Record<string, string> {
440
720
  const hashes: Record<string, string> = {};
@@ -484,6 +764,119 @@ function listProjectInputFiles(root: string): string[] {
484
764
  return out;
485
765
  }
486
766
 
767
+ /**
768
+ * Report whether an absolute `file` belongs to the project walk universe of
769
+ * `root`: it lies under `root` and no segment of the relative path (including
770
+ * the basename) is an ignored directory name. The predicate mirrors
771
+ * {@link listProjectInputFiles} exactly, so "walk-visible" here means "hashed by
772
+ * {@link collectProjectInputHashes}". Anything else is an out-of-walk input that
773
+ * only the reference graph can prove relevant.
774
+ */
775
+ export function isProjectWalkPath(root: string, file: string): boolean {
776
+ const relative = path.relative(path.resolve(root), path.resolve(file));
777
+ if (
778
+ relative.length === 0 ||
779
+ relative === ".." ||
780
+ relative.startsWith(`..${path.sep}`) ||
781
+ path.isAbsolute(relative)
782
+ ) {
783
+ return false;
784
+ }
785
+ return relative
786
+ .split(path.sep)
787
+ .every((segment) => !isIgnoredProjectDirectory(segment));
788
+ }
789
+
790
+ /**
791
+ * Hash a list of absolute out-of-walk input paths: content SHA-256 for a
792
+ * readable file, a stable `missing` marker otherwise. The marker is state, not
793
+ * an error — a recorded input disappearing (or reappearing) must change the
794
+ * comparison exactly like a content edit. Exported so `@ttsc/metro` can re-hash
795
+ * its recorded snapshot with identical semantics at cache-key time.
796
+ */
797
+ export function collectExternalInputHashes(
798
+ paths: readonly string[],
799
+ ): Record<string, string> {
800
+ const hashes: Record<string, string> = {};
801
+ for (const file of paths) {
802
+ try {
803
+ hashes[file] = hashText(fs.readFileSync(file));
804
+ } catch {
805
+ hashes[file] = "missing";
806
+ }
807
+ }
808
+ return hashes;
809
+ }
810
+
811
+ /**
812
+ * Derive the absolute out-of-walk input set of a whole project transform: the
813
+ * union of every reference-graph member (edge keys and targets, globals, the
814
+ * config chain) and every plugin-reported dependency, minus everything the
815
+ * project walk already hashes and the disposed temp-dir tsconfig. These are the
816
+ * inputs {@link matchesCachedSource}'s walk cannot see.
817
+ *
818
+ * A `dependenciesComplete` declaration deliberately does not narrow this set,
819
+ * unlike the per-file watch derivation. This cache replays one whole envelope,
820
+ * so its validity condition is the union over every file the envelope carries
821
+ * rather than one file's inputs; a miss here costs a re-transform, never a
822
+ * stale output; and it is the layer that re-runs the plugin's analysis, which
823
+ * is how a widened declaration is ever learned. The narrowing that matters
824
+ * lands at the bundler boundary through {@link selectWatchInputs}, which is what
825
+ * feeds persistent caches and watch graphs.
826
+ */
827
+ function selectExternalInputPaths(props: {
828
+ projectRoot: string;
829
+ result: ITtscCompilerTransformation;
830
+ temporaryTsconfig?: string;
831
+ }): string[] {
832
+ if (props.result.type === "exception") {
833
+ return [];
834
+ }
835
+ const members: string[] = [];
836
+ const graph = props.result.graph;
837
+ if (graph !== undefined) {
838
+ for (const [source, targets] of Object.entries(graph.edges ?? {})) {
839
+ members.push(source);
840
+ if (Array.isArray(targets)) {
841
+ members.push(...targets);
842
+ }
843
+ }
844
+ for (const listed of [graph.globals, graph.configs]) {
845
+ if (Array.isArray(listed)) {
846
+ members.push(...listed);
847
+ }
848
+ }
849
+ }
850
+ for (const entries of Object.values(props.result.dependencies ?? {})) {
851
+ if (Array.isArray(entries)) {
852
+ members.push(...entries);
853
+ }
854
+ }
855
+ const excluded =
856
+ props.temporaryTsconfig === undefined
857
+ ? undefined
858
+ : path.resolve(props.temporaryTsconfig);
859
+ const output: string[] = [];
860
+ const seen = new Set<string>();
861
+ for (const member of members) {
862
+ if (typeof member !== "string" || member.length === 0) {
863
+ continue;
864
+ }
865
+ const absolute = path.resolve(props.projectRoot, member);
866
+ if (
867
+ absolute === excluded ||
868
+ seen.has(absolute) ||
869
+ isProjectWalkPath(props.projectRoot, absolute)
870
+ ) {
871
+ continue;
872
+ }
873
+ seen.add(absolute);
874
+ output.push(absolute);
875
+ }
876
+ output.sort();
877
+ return output;
878
+ }
879
+
487
880
  function isIgnoredProjectDirectory(name: string): boolean {
488
881
  return (
489
882
  name === ".git" ||
@@ -544,7 +937,15 @@ async function transformProject(props: {
544
937
  projectRoot,
545
938
  tsconfig: configured.path,
546
939
  }).transform();
940
+ const temporaryTsconfig =
941
+ configured.path === props.tsconfig ? undefined : configured.path;
547
942
  return {
943
+ // Capture the out-of-walk input hashes while the generation is fresh so
944
+ // cache validation can re-check them; computed before dispose so the
945
+ // exclusion of the temp-dir tsconfig is the only reason it never keys.
946
+ externalInputHashes: collectExternalInputHashes(
947
+ selectExternalInputPaths({ projectRoot, result, temporaryTsconfig }),
948
+ ),
548
949
  inputHashes: collectInputHashes({
549
950
  currentFile: props.currentFile,
550
951
  currentSource: props.currentSource,
@@ -552,6 +953,10 @@ async function transformProject(props: {
552
953
  }),
553
954
  projectRoot,
554
955
  result,
956
+ // Remember the generated temp-dir tsconfig (disposed below) so watch
957
+ // derivation can drop it from the envelope's config chain; a registered
958
+ // but deleted file would invalidate every persistent-cache snapshot.
959
+ ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
555
960
  };
556
961
  } finally {
557
962
  configured.dispose();
package/src/turbopack.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { TtscUnpluginOptions } from "./core/options";
2
2
  import { resolveOptions } from "./core/options";
3
+ import type { TtscTransformHooks } from "./core/transform";
3
4
  import {
4
5
  createTtscTransformCache,
5
6
  isDeclarationFile,
@@ -29,6 +30,13 @@ export interface TtscTurbopackLoaderContext {
29
30
  * build that predates the method) still loads.
30
31
  */
31
32
  addDependency?(file: string): void;
33
+ /**
34
+ * Toggle result cacheability. Part of the webpack loader context contract;
35
+ * called with `false` when the ttsc plugin declared the module volatile
36
+ * (output depends on non-file inputs), so the bundler never replays a cached
37
+ * result for it. Optional so a minimal stub context still loads.
38
+ */
39
+ cacheable?(flag: boolean): void;
32
40
  }
33
41
 
34
42
  /** Matches any path segment that is a `node_modules` directory (cross-platform). */
@@ -79,19 +87,29 @@ export default function turbopack(
79
87
  callback(undefined, source);
80
88
  return;
81
89
  }
82
- // Forward plugin-reported dependencies into Turbopack's `fileDependencies`
83
- // set so editing a type-only input a transform consulted re-runs this loader.
90
+ // Forward the derived watch inputs (plugin-reported dependencies plus the
91
+ // host-owned reference graph) into Turbopack's `fileDependencies` set so
92
+ // editing a type-only input a transform consulted re-runs this loader.
84
93
  // `addDependency` is bound so the webpack loader context stays `this` inside
85
94
  // it; the hook fires on cache hits too, which is required because the shared
86
- // transform cache lives for the worker lifetime across requests.
95
+ // transform cache lives for the worker lifetime across requests. A module
96
+ // the plugin declared volatile is marked uncacheable through the same loader
97
+ // contract.
87
98
  const addDependency = this.addDependency?.bind(this);
99
+ const cacheable = this.cacheable?.bind(this);
100
+ const hooks: TtscTransformHooks = {
101
+ ...(addDependency === undefined ? {} : { addWatchFile: addDependency }),
102
+ ...(cacheable === undefined
103
+ ? {}
104
+ : { markVolatile: () => cacheable(false) }),
105
+ };
88
106
  transformTtsc(
89
107
  file,
90
108
  source,
91
109
  resolveOptions(this.getOptions?.() ?? {}),
92
110
  undefined,
93
111
  transformCache,
94
- addDependency === undefined ? undefined : { addWatchFile: addDependency },
112
+ Object.keys(hooks).length === 0 ? undefined : hooks,
95
113
  ).then(
96
114
  (result) => callback(undefined, result?.code ?? source),
97
115
  (error) => callback(error),