@atlaspack/core 2.16.2-canary.57 → 2.16.2-canary.59

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.
@@ -28,6 +28,82 @@ const internalConfigToConfig: DefaultWeakMap<
28
28
  WeakMap<Config, PublicConfig>,
29
29
  > = new DefaultWeakMap(() => new WeakMap());
30
30
 
31
+ /**
32
+ * Implements read tracking over an object.
33
+ *
34
+ * Calling this function with a non-trivial object like a class instance will fail to work.
35
+ *
36
+ * We track reads to fields that resolve to:
37
+ *
38
+ * - primitive values
39
+ * - arrays
40
+ *
41
+ * That is, reading a nested field `a.b.c` will make a single call to `onRead` with the path
42
+ * `['a', 'b', 'c']`.
43
+ *
44
+ * In case the value is null or an array, we will track the read as well.
45
+ *
46
+ * Iterating over `Object.keys(obj.field)` will register a read for the `['field']` path.
47
+ * Other reads work normally.
48
+ *
49
+ * @example
50
+ *
51
+ * const usedPaths = new Set();
52
+ * const onRead = (path) => {
53
+ * usedPaths.add(path);
54
+ * };
55
+ *
56
+ * const config = makeConfigProxy(onRead, {a: {b: {c: 'd'}}})
57
+ * console.log(config.a.b.c);
58
+ * console.log(Array.from(usedPaths));
59
+ * // We get a single read for the path
60
+ * // ['a', 'b', 'c']
61
+ *
62
+ */
63
+ export function makeConfigProxy<T>(
64
+ onRead: (path: string[]) => void,
65
+ config: T,
66
+ ): T {
67
+ const reportedPaths = new Set();
68
+ const reportPath = (path) => {
69
+ if (reportedPaths.has(path)) {
70
+ return;
71
+ }
72
+ reportedPaths.add(path);
73
+ onRead(path);
74
+ };
75
+
76
+ const makeProxy = (target, path) => {
77
+ return new Proxy(target, {
78
+ ownKeys(target) {
79
+ reportPath(path);
80
+
81
+ // $FlowFixMe
82
+ return Object.getOwnPropertyNames(target);
83
+ },
84
+ get(target, prop) {
85
+ // $FlowFixMe
86
+ const value = target[prop];
87
+
88
+ if (
89
+ typeof value === 'object' &&
90
+ value != null &&
91
+ !Array.isArray(value)
92
+ ) {
93
+ return makeProxy(value, [...path, prop]);
94
+ }
95
+
96
+ reportPath([...path, prop]);
97
+
98
+ return value;
99
+ },
100
+ });
101
+ };
102
+
103
+ // $FlowFixMe
104
+ return makeProxy(config, []);
105
+ }
106
+
31
107
  export default class PublicConfig implements IConfig {
32
108
  #config /*: Config */;
33
109
  #pkg /*: ?PackageJSON */;
@@ -77,7 +153,7 @@ export default class PublicConfig implements IConfig {
77
153
  );
78
154
  }
79
155
 
80
- invalidateOnConfigKeyChange(filePath: FilePath, configKey: string) {
156
+ invalidateOnConfigKeyChange(filePath: FilePath, configKey: string[]) {
81
157
  this.#config.invalidateOnConfigKeyChange.push({
82
158
  filePath: toProjectPath(this.#options.projectRoot, filePath),
83
159
  configKey,
@@ -145,9 +221,10 @@ export default class PublicConfig implements IConfig {
145
221
  |}
146
222
  | ?{|
147
223
  /**
148
- * If specified, only invalidate when this config key changes.
224
+ * If specified, this function will return a proxy object that will track reads to
225
+ * config fields and only register invalidations for when those keys change.
149
226
  */
150
- configKey?: string,
227
+ readTracking?: boolean,
151
228
  |},
152
229
  ): Promise<?ConfigResultWithFilePath<T>> {
153
230
  let packageKey = options?.packageKey;
@@ -158,7 +235,7 @@ export default class PublicConfig implements IConfig {
158
235
 
159
236
  if (pkg && pkg.contents[packageKey]) {
160
237
  // Invalidate only when the package key changes
161
- this.invalidateOnConfigKeyChange(pkg.filePath, packageKey);
238
+ this.invalidateOnConfigKeyChange(pkg.filePath, [packageKey]);
162
239
 
163
240
  return {
164
241
  contents: pkg.contents[packageKey],
@@ -167,27 +244,24 @@ export default class PublicConfig implements IConfig {
167
244
  }
168
245
  }
169
246
 
170
- if (getFeatureFlag('granularTsConfigInvalidation')) {
171
- const configKey = options?.configKey;
172
- if (configKey != null) {
173
- for (let fileName of fileNames) {
174
- let config = await this.getConfigFrom(searchPath, [fileName], {
175
- exclude: true,
247
+ const readTracking = options?.readTracking;
248
+ if (readTracking === true) {
249
+ for (let fileName of fileNames) {
250
+ const config = await this.getConfigFrom(searchPath, [fileName], {
251
+ exclude: true,
252
+ });
253
+
254
+ if (config != null) {
255
+ return Promise.resolve({
256
+ contents: makeConfigProxy((keyPath) => {
257
+ this.invalidateOnConfigKeyChange(config.filePath, keyPath);
258
+ }, config.contents),
259
+ filePath: config.filePath,
176
260
  });
177
-
178
- if (config && config.contents[configKey]) {
179
- // Invalidate only when the package key changes
180
- this.invalidateOnConfigKeyChange(config.filePath, configKey);
181
-
182
- return {
183
- contents: config.contents[configKey],
184
- filePath: config.filePath,
185
- };
186
- }
187
261
  }
188
-
189
- // fall through so that file above invalidations are registered
190
262
  }
263
+
264
+ // fall through so that file above invalidations are registered
191
265
  }
192
266
 
193
267
  if (fileNames.length === 0) {
@@ -269,11 +343,15 @@ export default class PublicConfig implements IConfig {
269
343
 
270
344
  getConfig<T>(
271
345
  filePaths: Array<FilePath>,
272
- options: ?{|
273
- packageKey?: string,
274
- parse?: boolean,
275
- exclude?: boolean,
276
- |},
346
+ options:
347
+ | ?{|
348
+ packageKey?: string,
349
+ parse?: boolean,
350
+ exclude?: boolean,
351
+ |}
352
+ | {|
353
+ readTracking?: boolean,
354
+ |},
277
355
  ): Promise<?ConfigResultWithFilePath<T>> {
278
356
  return this.getConfigFrom(this.searchPath, filePaths, options);
279
357
  }
@@ -283,7 +361,9 @@ export default class PublicConfig implements IConfig {
283
361
  return this.#pkg;
284
362
  }
285
363
 
286
- let pkgConfig = await this.getConfig<PackageJSON>(['package.json']);
364
+ let pkgConfig = await this.getConfig<PackageJSON>(['package.json'], {
365
+ readTracking: getFeatureFlag('granularTsConfigInvalidation'),
366
+ });
287
367
  if (!pkgConfig) {
288
368
  return null;
289
369
  }
@@ -63,7 +63,7 @@ export type ConfigRequest = {
63
63
  invalidateOnFileChange: Set<ProjectPath>,
64
64
  invalidateOnConfigKeyChange: Array<{|
65
65
  filePath: ProjectPath,
66
- configKey: string,
66
+ configKey: string[],
67
67
  |}>,
68
68
  invalidateOnFileCreate: Array<InternalFileCreateInvalidation>,
69
69
  invalidateOnEnvChange: Set<string>,
@@ -108,34 +108,58 @@ export async function loadPluginConfig<T: PluginWithLoadConfig>(
108
108
  }
109
109
  }
110
110
 
111
+ /**
112
+ * Return value at a given key path within an object.
113
+ *
114
+ * @example
115
+ * const obj = { a: { b: { c: 'd' } } };
116
+ * getValueAtPath(obj, ['a', 'b', 'c']); // 'd'
117
+ * getValueAtPath(obj, ['a', 'b', 'd']); // undefined
118
+ * getValueAtPath(obj, ['a', 'b']); // { c: 'd' }
119
+ * getValueAtPath(obj, ['a', 'b', 'c', 'd']); // undefined
120
+ */
121
+ export function getValueAtPath(obj: Object, key: string[]): any {
122
+ let current = obj;
123
+ for (let part of key) {
124
+ if (current == null) {
125
+ return undefined;
126
+ }
127
+ current = current[part];
128
+ }
129
+ return current;
130
+ }
131
+
111
132
  const configKeyCache = createBuildCache();
112
133
 
113
134
  export async function getConfigKeyContentHash(
114
135
  filePath: ProjectPath,
115
- configKey: string,
136
+ configKey: string[],
116
137
  options: AtlaspackOptions,
117
138
  ): Async<string> {
118
- let cacheKey = `${fromProjectPathRelative(filePath)}:${configKey}`;
139
+ let cacheKey = `${fromProjectPathRelative(filePath)}:${JSON.stringify(
140
+ configKey,
141
+ )}`;
119
142
  let cachedValue = configKeyCache.get(cacheKey);
120
143
 
121
144
  if (cachedValue) {
122
145
  return cachedValue;
123
146
  }
124
147
 
125
- let conf = await readConfig(
148
+ const conf = await readConfig(
126
149
  options.inputFS,
127
150
  fromProjectPath(options.projectRoot, filePath),
128
151
  );
129
152
 
130
- if (conf == null || conf.config[configKey] == null) {
153
+ const value = getValueAtPath(conf?.config, configKey);
154
+ if (conf == null || value == null) {
131
155
  // This can occur when a config key has been removed entirely during `respondToFSEvents`
132
156
  return '';
133
157
  }
134
158
 
135
- let contentHash =
136
- typeof conf.config[configKey] === 'object'
137
- ? hashObject(conf.config[configKey])
138
- : hashString(JSON.stringify(conf.config[configKey]));
159
+ const contentHash =
160
+ typeof value === 'object'
161
+ ? hashObject(value)
162
+ : hashString(JSON.stringify(value));
139
163
 
140
164
  configKeyCache.set(cacheKey, contentHash);
141
165
 
package/src/types.js CHANGED
@@ -499,7 +499,7 @@ export type Config = {|
499
499
  invalidateOnFileChange: Set<ProjectPath>,
500
500
  invalidateOnConfigKeyChange: Array<{|
501
501
  filePath: ProjectPath,
502
- configKey: string,
502
+ configKey: string[],
503
503
  |}>,
504
504
  invalidateOnFileCreate: Array<InternalFileCreateInvalidation>,
505
505
  invalidateOnEnvChange: Set<string>,
@@ -0,0 +1,192 @@
1
+ // @flow strict-local
2
+
3
+ import assert from 'assert';
4
+ import nullthrows from 'nullthrows';
5
+ import sinon from 'sinon';
6
+ import {ATLASPACK_VERSION} from '../src/constants';
7
+ import {DEFAULT_FEATURE_FLAGS, setFeatureFlags} from '@atlaspack/feature-flags';
8
+ import {setAllEnvironments, getAllEnvironments} from '@atlaspack/rust';
9
+ import {
10
+ loadEnvironmentsFromCache,
11
+ writeEnvironmentsToCache,
12
+ } from '../src/EnvironmentManager';
13
+ import {DEFAULT_OPTIONS} from './test-utils';
14
+ import {LMDBLiteCache} from '@atlaspack/cache';
15
+
16
+ const options = {
17
+ ...DEFAULT_OPTIONS,
18
+ cache: new LMDBLiteCache(DEFAULT_OPTIONS.cacheDir),
19
+ };
20
+
21
+ describe('EnvironmentManager', () => {
22
+ const env1 = {
23
+ id: 'd821e85f6b50315e',
24
+ context: 'browser',
25
+ engines: {browsers: ['> 0.25%']},
26
+ includeNodeModules: true,
27
+ outputFormat: 'global',
28
+ isLibrary: false,
29
+ shouldOptimize: false,
30
+ shouldScopeHoist: false,
31
+ loc: undefined,
32
+ sourceMap: undefined,
33
+ sourceType: 'module',
34
+ unstableSingleFileOutput: false,
35
+ };
36
+ const env2 = {
37
+ id: 'de92f48baa8448d2',
38
+ context: 'node',
39
+ engines: {
40
+ browsers: [],
41
+ node: '>= 8',
42
+ },
43
+ includeNodeModules: false,
44
+ outputFormat: 'commonjs',
45
+ isLibrary: true,
46
+ shouldOptimize: true,
47
+ shouldScopeHoist: true,
48
+ loc: null,
49
+ sourceMap: null,
50
+ sourceType: 'module',
51
+ unstableSingleFileOutput: false,
52
+ };
53
+
54
+ beforeEach(async () => {
55
+ await options.cache.ensure();
56
+
57
+ for (const key of options.cache.keys()) {
58
+ await options.cache.getNativeRef().delete(key);
59
+ }
60
+ setAllEnvironments([]);
61
+
62
+ setFeatureFlags({
63
+ ...DEFAULT_FEATURE_FLAGS,
64
+ environmentDeduplication: true,
65
+ });
66
+ });
67
+
68
+ it('should store environments by ID in the cache', async () => {
69
+ setAllEnvironments([env1]);
70
+ await writeEnvironmentsToCache(options.cache);
71
+
72
+ const cachedEnv1 = await options.cache.get(
73
+ `Environment/${ATLASPACK_VERSION}/${env1.id}`,
74
+ );
75
+ assert.deepEqual(cachedEnv1, env1, 'Environment 1 should be cached');
76
+ });
77
+
78
+ it('should list all environment IDs in the environment manager', async () => {
79
+ const environmentIds = [env1.id, env2.id];
80
+ setAllEnvironments([env1, env2]);
81
+ await writeEnvironmentsToCache(options.cache);
82
+
83
+ const cachedEnvIds = await options.cache.get(
84
+ `EnvironmentManager/${ATLASPACK_VERSION}`,
85
+ );
86
+ const cachedIdsArray = nullthrows(cachedEnvIds);
87
+ assert.equal(
88
+ cachedIdsArray.length,
89
+ environmentIds.length,
90
+ 'Should have same number of IDs',
91
+ );
92
+ assert(
93
+ environmentIds.every((id) => cachedIdsArray.includes(id)),
94
+ 'All environment IDs should be present in cache',
95
+ );
96
+ });
97
+
98
+ it('should write all environments to cache using writeEnvironmentsToCache', async () => {
99
+ setAllEnvironments([env1, env2]);
100
+ await writeEnvironmentsToCache(options.cache);
101
+
102
+ // Verify each environment was stored individually
103
+ const cachedEnv1 = await options.cache.get(
104
+ `Environment/${ATLASPACK_VERSION}/${env1.id}`,
105
+ );
106
+ const cachedEnv2 = await options.cache.get(
107
+ `Environment/${ATLASPACK_VERSION}/${env2.id}`,
108
+ );
109
+ assert.deepEqual(cachedEnv1, env1, 'Environment 1 should be cached');
110
+ assert.deepEqual(cachedEnv2, env2, 'Environment 2 should be cached');
111
+
112
+ // Verify environment IDs were stored in manager
113
+ const cachedEnvIds = await options.cache.get(
114
+ `EnvironmentManager/${ATLASPACK_VERSION}`,
115
+ );
116
+ const cachedIdsArray = nullthrows(cachedEnvIds);
117
+ assert(
118
+ cachedIdsArray.length === 2 &&
119
+ [env1.id, env2.id].every((id) => cachedIdsArray.includes(id)),
120
+ 'Environment IDs should be stored in manager',
121
+ );
122
+ });
123
+
124
+ it('should load environments from cache on loadRequestGraph on a subsequent build', async () => {
125
+ // Simulate cache written on a first build
126
+ setAllEnvironments([env1, env2]);
127
+ await writeEnvironmentsToCache(options.cache);
128
+
129
+ await loadEnvironmentsFromCache(options.cache);
130
+
131
+ const loadedEnvironments = getAllEnvironments();
132
+ assert.equal(
133
+ loadedEnvironments.length,
134
+ 2,
135
+ 'Should load 2 environments from cache',
136
+ );
137
+
138
+ const env1Loaded = loadedEnvironments.find((e) => e.id === env1.id);
139
+ const env2Loaded = loadedEnvironments.find((e) => e.id === env2.id);
140
+
141
+ assert.deepEqual(
142
+ env1Loaded,
143
+ env1,
144
+ 'First environment should match cached environment',
145
+ );
146
+ assert.deepEqual(
147
+ env2Loaded,
148
+ env2,
149
+ 'Second environment should match cached environment',
150
+ );
151
+ });
152
+
153
+ it('should handle empty cache gracefully without calling setAllEnvironments', async () => {
154
+ const setAllEnvironmentsSpy = sinon.spy(setAllEnvironments);
155
+
156
+ await assert.doesNotReject(
157
+ loadEnvironmentsFromCache(options.cache),
158
+ 'loadEnvironmentsFromCache should not throw when cache is empty',
159
+ );
160
+
161
+ assert.equal(
162
+ setAllEnvironmentsSpy.callCount,
163
+ 0,
164
+ 'setAllEnvironments should not be called when loading from empty cache',
165
+ );
166
+ });
167
+
168
+ it('should not load environments from a different version', async () => {
169
+ const setAllEnvironmentsSpy = sinon.spy(setAllEnvironments);
170
+ const differentVersion = '2.17.2'; // A different version than ATLASPACK_VERSION
171
+
172
+ // Store an environment with a different version
173
+ await options.cache.set(`Environment/${differentVersion}/${env1.id}`, env1);
174
+ await options.cache.set(`EnvironmentManager/${differentVersion}`, [
175
+ env1.id,
176
+ ]);
177
+
178
+ await loadEnvironmentsFromCache(options.cache);
179
+
180
+ assert.equal(
181
+ setAllEnvironmentsSpy.callCount,
182
+ 0,
183
+ 'setAllEnvironments should not be called when loading from different version',
184
+ );
185
+ const loadedEnvironments = getAllEnvironments();
186
+ assert.equal(
187
+ loadedEnvironments.length,
188
+ 0,
189
+ 'Should not load any environments from different version',
190
+ );
191
+ });
192
+ });
@@ -0,0 +1,108 @@
1
+ // @flow strict-local
2
+
3
+ import sinon from 'sinon';
4
+ import {makeConfigProxy} from '../../src/public/Config';
5
+ import assert from 'assert';
6
+
7
+ describe('makeConfigProxy', () => {
8
+ it('tracks reads to nested fields', () => {
9
+ const onRead = sinon.spy();
10
+ const target = {a: {b: {c: 'd'}}};
11
+ const config = makeConfigProxy(onRead, target);
12
+ config.a.b.c;
13
+ assert.ok(onRead.calledWith(['a', 'b', 'c']));
14
+ assert.ok(onRead.calledOnce);
15
+ });
16
+
17
+ it('works for reading package.json dependencies', () => {
18
+ const packageJson = {
19
+ dependencies: {
20
+ react: '18.2.0',
21
+ },
22
+ };
23
+
24
+ const onRead = sinon.spy();
25
+ const config = makeConfigProxy(onRead, packageJson);
26
+ assert.equal(config.dependencies.react, '18.2.0');
27
+ // $FlowFixMe
28
+ assert.equal(config.dependencies.preact, undefined);
29
+ assert.ok(onRead.calledWith(['dependencies', 'react']));
30
+ assert.ok(onRead.calledWith(['dependencies', 'preact']));
31
+ assert.equal(onRead.callCount, 2);
32
+ });
33
+
34
+ it('will track reads for any missing or null keys', () => {
35
+ const packageJson = {
36
+ dependencies: {
37
+ react: '18.2.0',
38
+ },
39
+ };
40
+
41
+ const onRead = sinon.spy();
42
+ const config = makeConfigProxy(onRead, packageJson);
43
+
44
+ // $FlowFixMe
45
+ assert.equal(config.alias?.react, undefined);
46
+ assert.ok(onRead.calledWith(['alias']));
47
+ assert.equal(onRead.callCount, 1);
48
+ });
49
+
50
+ it('iterating over keys works normally and will register a read for the key being enumerated', () => {
51
+ const packageJson = {
52
+ nested: {
53
+ dependencies: {
54
+ react: '18.2.0',
55
+ 'react-dom': '18.2.0',
56
+ 'react-router': '6.14.2',
57
+ },
58
+ },
59
+ };
60
+
61
+ const onRead = sinon.spy();
62
+ const config = makeConfigProxy(onRead, packageJson);
63
+ assert.equal(Object.keys(config.nested.dependencies).length, 3);
64
+
65
+ assert.ok(onRead.calledWith(['nested', 'dependencies']));
66
+ });
67
+
68
+ it('if a key has an array value we will track a read for that key', () => {
69
+ const packageJson = {
70
+ scripts: ['build', 'test'],
71
+ };
72
+
73
+ const onRead = sinon.spy();
74
+ const config = makeConfigProxy(onRead, packageJson);
75
+ assert.equal(config.scripts[0], 'build');
76
+ assert.equal(onRead.callCount, 1);
77
+ assert.ok(onRead.calledWith(['scripts']));
78
+ });
79
+
80
+ it('if a key array value is iterated over we will track a read for that key', () => {
81
+ const packageJson = {
82
+ scripts: ['build', 'test'],
83
+ };
84
+
85
+ const onRead = sinon.spy();
86
+ const config = makeConfigProxy(onRead, packageJson);
87
+ let scriptCount = 0;
88
+ // eslint-disable-next-line no-unused-vars
89
+ for (const _script of config.scripts) {
90
+ scriptCount += 1;
91
+ }
92
+ assert.equal(scriptCount, 2);
93
+ assert.ok(onRead.calledWith(['scripts']));
94
+ assert.equal(onRead.callCount, 1);
95
+ });
96
+
97
+ it('if a key array value length is verified we will track a read for that key', () => {
98
+ const packageJson = {
99
+ scripts: ['build', 'test'],
100
+ };
101
+
102
+ const onRead = sinon.spy();
103
+ const config = makeConfigProxy(onRead, packageJson);
104
+ assert.equal(config.scripts.length, 2);
105
+ assert.ok(onRead.calledWith(['scripts']));
106
+ assert.equal(onRead.callCount, 1);
107
+ });
108
+ });