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

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.
@@ -162,7 +162,7 @@ const nodeFromOption = (option, value) => ({
162
162
  hash: (0, _utils2.hashFromOption)(value)
163
163
  });
164
164
  const nodeFromConfigKey = (fileName, configKey, contentHash) => ({
165
- id: `config_key:${(0, _projectPath.fromProjectPathRelative)(fileName)}:${configKey}`,
165
+ id: `config_key:${(0, _projectPath.fromProjectPathRelative)(fileName)}:${JSON.stringify(configKey)}`,
166
166
  type: CONFIG_KEY,
167
167
  configKey,
168
168
  contentHash
@@ -695,10 +695,18 @@ class RequestGraph extends _graph().ContentGraph {
695
695
  this.removeNode(nodeId, removeOrphans);
696
696
  }
697
697
  let configKeyNodes = this.configKeyNodes.get(_filePath);
698
- if (configKeyNodes && (type === 'delete' || type === 'update')) {
698
+
699
+ // With granular invalidations we will always run this block,
700
+ // so even if we get a create event (for whatever reason), we will still
701
+ // try to limit invalidations from config key changes through hashing.
702
+ //
703
+ // Currently create events can invalidate a large number of nodes due to
704
+ // "create above" invalidations.
705
+ const isConfigKeyChange = (0, _featureFlags().getFeatureFlag)('granularTsConfigInvalidation') || type === 'delete' || type === 'update';
706
+ if (configKeyNodes && isConfigKeyChange) {
699
707
  for (let nodeId of configKeyNodes) {
700
708
  let isInvalid = type === 'delete';
701
- if (type === 'update') {
709
+ if (type !== 'delete') {
702
710
  let node = this.getNode(nodeId);
703
711
  (0, _assert().default)(node && node.type === CONFIG_KEY);
704
712
  let contentHash = await (0, _ConfigRequest.getConfigKeyContentHash)(_filePath, node.configKey, options);
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.default = void 0;
7
+ exports.makeConfigProxy = makeConfigProxy;
7
8
  function _assert() {
8
9
  const data = _interopRequireDefault(require("assert"));
9
10
  _assert = function () {
@@ -37,6 +38,71 @@ function _featureFlags() {
37
38
  var _EnvironmentManager = require("../EnvironmentManager");
38
39
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
39
40
  const internalConfigToConfig = new (_utils().DefaultWeakMap)(() => new WeakMap());
41
+
42
+ /**
43
+ * Implements read tracking over an object.
44
+ *
45
+ * Calling this function with a non-trivial object like a class instance will fail to work.
46
+ *
47
+ * We track reads to fields that resolve to:
48
+ *
49
+ * - primitive values
50
+ * - arrays
51
+ *
52
+ * That is, reading a nested field `a.b.c` will make a single call to `onRead` with the path
53
+ * `['a', 'b', 'c']`.
54
+ *
55
+ * In case the value is null or an array, we will track the read as well.
56
+ *
57
+ * Iterating over `Object.keys(obj.field)` will register a read for the `['field']` path.
58
+ * Other reads work normally.
59
+ *
60
+ * @example
61
+ *
62
+ * const usedPaths = new Set();
63
+ * const onRead = (path) => {
64
+ * usedPaths.add(path);
65
+ * };
66
+ *
67
+ * const config = makeConfigProxy(onRead, {a: {b: {c: 'd'}}})
68
+ * console.log(config.a.b.c);
69
+ * console.log(Array.from(usedPaths));
70
+ * // We get a single read for the path
71
+ * // ['a', 'b', 'c']
72
+ *
73
+ */
74
+ function makeConfigProxy(onRead, config) {
75
+ const reportedPaths = new Set();
76
+ const reportPath = path => {
77
+ if (reportedPaths.has(path)) {
78
+ return;
79
+ }
80
+ reportedPaths.add(path);
81
+ onRead(path);
82
+ };
83
+ const makeProxy = (target, path) => {
84
+ return new Proxy(target, {
85
+ ownKeys(target) {
86
+ reportPath(path);
87
+
88
+ // $FlowFixMe
89
+ return Object.getOwnPropertyNames(target);
90
+ },
91
+ get(target, prop) {
92
+ // $FlowFixMe
93
+ const value = target[prop];
94
+ if (typeof value === 'object' && value != null && !Array.isArray(value)) {
95
+ return makeProxy(value, [...path, prop]);
96
+ }
97
+ reportPath([...path, prop]);
98
+ return value;
99
+ }
100
+ });
101
+ };
102
+
103
+ // $FlowFixMe
104
+ return makeProxy(config, []);
105
+ }
40
106
  class PublicConfig {
41
107
  #config /*: Config */;
42
108
  #pkg /*: ?PackageJSON */;
@@ -127,32 +193,30 @@ class PublicConfig {
127
193
  });
128
194
  if (pkg && pkg.contents[packageKey]) {
129
195
  // Invalidate only when the package key changes
130
- this.invalidateOnConfigKeyChange(pkg.filePath, packageKey);
196
+ this.invalidateOnConfigKeyChange(pkg.filePath, [packageKey]);
131
197
  return {
132
198
  contents: pkg.contents[packageKey],
133
199
  filePath: pkg.filePath
134
200
  };
135
201
  }
136
202
  }
137
- if ((0, _featureFlags().getFeatureFlag)('granularTsConfigInvalidation')) {
138
- const configKey = options === null || options === void 0 ? void 0 : options.configKey;
139
- if (configKey != null) {
140
- for (let fileName of fileNames) {
141
- let config = await this.getConfigFrom(searchPath, [fileName], {
142
- exclude: true
203
+ const readTracking = options === null || options === void 0 ? void 0 : options.readTracking;
204
+ if (readTracking === true) {
205
+ for (let fileName of fileNames) {
206
+ const config = await this.getConfigFrom(searchPath, [fileName], {
207
+ exclude: true
208
+ });
209
+ if (config != null) {
210
+ return Promise.resolve({
211
+ contents: makeConfigProxy(keyPath => {
212
+ this.invalidateOnConfigKeyChange(config.filePath, keyPath);
213
+ }, config.contents),
214
+ filePath: config.filePath
143
215
  });
144
- if (config && config.contents[configKey]) {
145
- // Invalidate only when the package key changes
146
- this.invalidateOnConfigKeyChange(config.filePath, configKey);
147
- return {
148
- contents: config.contents[configKey],
149
- filePath: config.filePath
150
- };
151
- }
152
216
  }
153
-
154
- // fall through so that file above invalidations are registered
155
217
  }
218
+
219
+ // fall through so that file above invalidations are registered
156
220
  }
157
221
 
158
222
  if (fileNames.length === 0) {
@@ -219,7 +283,9 @@ class PublicConfig {
219
283
  if (this.#pkg) {
220
284
  return this.#pkg;
221
285
  }
222
- let pkgConfig = await this.getConfig(['package.json']);
286
+ let pkgConfig = await this.getConfig(['package.json'], {
287
+ readTracking: (0, _featureFlags().getFeatureFlag)('granularTsConfigInvalidation')
288
+ });
223
289
  if (!pkgConfig) {
224
290
  return null;
225
291
  }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.getConfigHash = getConfigHash;
7
7
  exports.getConfigKeyContentHash = getConfigKeyContentHash;
8
8
  exports.getConfigRequests = getConfigRequests;
9
+ exports.getValueAtPath = getValueAtPath;
9
10
  exports.loadPluginConfig = loadPluginConfig;
10
11
  exports.runConfigRequest = runConfigRequest;
11
12
  function _utils() {
@@ -86,19 +87,41 @@ async function loadPluginConfig(loadedPlugin, config, options) {
86
87
  });
87
88
  }
88
89
  }
90
+
91
+ /**
92
+ * Return value at a given key path within an object.
93
+ *
94
+ * @example
95
+ * const obj = { a: { b: { c: 'd' } } };
96
+ * getValueAtPath(obj, ['a', 'b', 'c']); // 'd'
97
+ * getValueAtPath(obj, ['a', 'b', 'd']); // undefined
98
+ * getValueAtPath(obj, ['a', 'b']); // { c: 'd' }
99
+ * getValueAtPath(obj, ['a', 'b', 'c', 'd']); // undefined
100
+ */
101
+ function getValueAtPath(obj, key) {
102
+ let current = obj;
103
+ for (let part of key) {
104
+ if (current == null) {
105
+ return undefined;
106
+ }
107
+ current = current[part];
108
+ }
109
+ return current;
110
+ }
89
111
  const configKeyCache = (0, _buildCache().createBuildCache)();
90
112
  async function getConfigKeyContentHash(filePath, configKey, options) {
91
- let cacheKey = `${(0, _projectPath.fromProjectPathRelative)(filePath)}:${configKey}`;
113
+ let cacheKey = `${(0, _projectPath.fromProjectPathRelative)(filePath)}:${JSON.stringify(configKey)}`;
92
114
  let cachedValue = configKeyCache.get(cacheKey);
93
115
  if (cachedValue) {
94
116
  return cachedValue;
95
117
  }
96
- let conf = await (0, _utils().readConfig)(options.inputFS, (0, _projectPath.fromProjectPath)(options.projectRoot, filePath));
97
- if (conf == null || conf.config[configKey] == null) {
118
+ const conf = await (0, _utils().readConfig)(options.inputFS, (0, _projectPath.fromProjectPath)(options.projectRoot, filePath));
119
+ const value = getValueAtPath(conf === null || conf === void 0 ? void 0 : conf.config, configKey);
120
+ if (conf == null || value == null) {
98
121
  // This can occur when a config key has been removed entirely during `respondToFSEvents`
99
122
  return '';
100
123
  }
101
- let contentHash = typeof conf.config[configKey] === 'object' ? (0, _utils().hashObject)(conf.config[configKey]) : (0, _rust().hashString)(JSON.stringify(conf.config[configKey]));
124
+ const contentHash = typeof value === 'object' ? (0, _utils().hashObject)(value) : (0, _rust().hashString)(JSON.stringify(value));
102
125
  configKeyCache.set(cacheKey, contentHash);
103
126
  return contentHash;
104
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaspack/core",
3
- "version": "2.16.2-canary.57+6dd4ccb75",
3
+ "version": "2.16.2-canary.58+1e32d4eae",
4
4
  "license": "(MIT OR Apache-2.0)",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -21,21 +21,21 @@
21
21
  "check-ts": "tsc --noEmit index.d.ts"
22
22
  },
23
23
  "dependencies": {
24
- "@atlaspack/build-cache": "2.13.3-canary.125+6dd4ccb75",
25
- "@atlaspack/cache": "3.1.1-canary.57+6dd4ccb75",
26
- "@atlaspack/diagnostic": "2.14.1-canary.125+6dd4ccb75",
27
- "@atlaspack/events": "2.14.1-canary.125+6dd4ccb75",
28
- "@atlaspack/feature-flags": "2.14.1-canary.125+6dd4ccb75",
29
- "@atlaspack/fs": "2.14.5-canary.57+6dd4ccb75",
30
- "@atlaspack/graph": "3.4.1-canary.125+6dd4ccb75",
31
- "@atlaspack/logger": "2.14.5-canary.57+6dd4ccb75",
32
- "@atlaspack/package-manager": "2.14.5-canary.57+6dd4ccb75",
33
- "@atlaspack/plugin": "2.14.5-canary.57+6dd4ccb75",
34
- "@atlaspack/profiler": "2.14.1-canary.125+6dd4ccb75",
35
- "@atlaspack/rust": "3.2.1-canary.57+6dd4ccb75",
36
- "@atlaspack/types": "2.14.5-canary.57+6dd4ccb75",
37
- "@atlaspack/utils": "2.14.5-canary.57+6dd4ccb75",
38
- "@atlaspack/workers": "2.14.5-canary.57+6dd4ccb75",
24
+ "@atlaspack/build-cache": "2.13.3-canary.126+1e32d4eae",
25
+ "@atlaspack/cache": "3.1.1-canary.58+1e32d4eae",
26
+ "@atlaspack/diagnostic": "2.14.1-canary.126+1e32d4eae",
27
+ "@atlaspack/events": "2.14.1-canary.126+1e32d4eae",
28
+ "@atlaspack/feature-flags": "2.14.1-canary.126+1e32d4eae",
29
+ "@atlaspack/fs": "2.14.5-canary.58+1e32d4eae",
30
+ "@atlaspack/graph": "3.4.1-canary.126+1e32d4eae",
31
+ "@atlaspack/logger": "2.14.5-canary.58+1e32d4eae",
32
+ "@atlaspack/package-manager": "2.14.5-canary.58+1e32d4eae",
33
+ "@atlaspack/plugin": "2.14.5-canary.58+1e32d4eae",
34
+ "@atlaspack/profiler": "2.14.1-canary.126+1e32d4eae",
35
+ "@atlaspack/rust": "3.2.1-canary.58+1e32d4eae",
36
+ "@atlaspack/types": "2.14.5-canary.58+1e32d4eae",
37
+ "@atlaspack/utils": "2.14.5-canary.58+1e32d4eae",
38
+ "@atlaspack/workers": "2.14.5-canary.58+1e32d4eae",
39
39
  "@mischnic/json-sourcemap": "^0.1.0",
40
40
  "@parcel/source-map": "^2.1.1",
41
41
  "base-x": "^3.0.8",
@@ -71,5 +71,5 @@
71
71
  "./src/serializerCore.js": "./src/serializerCore.browser.js"
72
72
  },
73
73
  "type": "commonjs",
74
- "gitHead": "6dd4ccb753541de32322d881f973d571dd57e4ca"
74
+ "gitHead": "1e32d4eae6b3af3968e8a0ef97d35b4347fd4196"
75
75
  }
@@ -24,7 +24,7 @@ type ConfigOpts = {|
24
24
  invalidateOnFileChange?: Set<ProjectPath>,
25
25
  invalidateOnConfigKeyChange?: Array<{|
26
26
  filePath: ProjectPath,
27
- configKey: string,
27
+ configKey: string[],
28
28
  |}>,
29
29
  invalidateOnFileCreate?: Array<InternalFileCreateInvalidation>,
30
30
  invalidateOnEnvChange?: Set<string>,
@@ -144,7 +144,7 @@ type OptionNode = {|
144
144
  type ConfigKeyNode = {|
145
145
  id: ContentKey,
146
146
  +type: typeof CONFIG_KEY,
147
- configKey: string,
147
+ configKey: string[],
148
148
  contentHash: string,
149
149
  |};
150
150
 
@@ -216,7 +216,7 @@ export type RunAPI<TResult: RequestResult> = {|
216
216
  invalidateOnFileUpdate: (ProjectPath) => void,
217
217
  invalidateOnConfigKeyChange: (
218
218
  filePath: ProjectPath,
219
- configKey: string,
219
+ configKey: string[],
220
220
  contentHash: string,
221
221
  ) => void,
222
222
  invalidateOnStartup: () => void,
@@ -283,10 +283,12 @@ const nodeFromOption = (option: string, value: mixed): RequestGraphNode => ({
283
283
 
284
284
  const nodeFromConfigKey = (
285
285
  fileName: ProjectPath,
286
- configKey: string,
286
+ configKey: string[],
287
287
  contentHash: string,
288
288
  ): RequestGraphNode => ({
289
- id: `config_key:${fromProjectPathRelative(fileName)}:${configKey}`,
289
+ id: `config_key:${fromProjectPathRelative(fileName)}:${JSON.stringify(
290
+ configKey,
291
+ )}`,
290
292
  type: CONFIG_KEY,
291
293
  configKey,
292
294
  contentHash,
@@ -527,7 +529,7 @@ export class RequestGraph extends ContentGraph<
527
529
  invalidateOnConfigKeyChange(
528
530
  requestNodeId: NodeId,
529
531
  filePath: ProjectPath,
530
- configKey: string,
532
+ configKey: string[],
531
533
  contentHash: string,
532
534
  ) {
533
535
  let configKeyNodeId = this.addNode(
@@ -1109,11 +1111,22 @@ export class RequestGraph extends ContentGraph<
1109
1111
  }
1110
1112
 
1111
1113
  let configKeyNodes = this.configKeyNodes.get(_filePath);
1112
- if (configKeyNodes && (type === 'delete' || type === 'update')) {
1114
+
1115
+ // With granular invalidations we will always run this block,
1116
+ // so even if we get a create event (for whatever reason), we will still
1117
+ // try to limit invalidations from config key changes through hashing.
1118
+ //
1119
+ // Currently create events can invalidate a large number of nodes due to
1120
+ // "create above" invalidations.
1121
+ const isConfigKeyChange =
1122
+ getFeatureFlag('granularTsConfigInvalidation') ||
1123
+ type === 'delete' ||
1124
+ type === 'update';
1125
+ if (configKeyNodes && isConfigKeyChange) {
1113
1126
  for (let nodeId of configKeyNodes) {
1114
1127
  let isInvalid = type === 'delete';
1115
1128
 
1116
- if (type === 'update') {
1129
+ if (type !== 'delete') {
1117
1130
  let node = this.getNode(nodeId);
1118
1131
  invariant(node && node.type === CONFIG_KEY);
1119
1132
 
@@ -107,7 +107,7 @@ export class PluginConfig implements IPluginConfig {
107
107
  exclude?: boolean,
108
108
  |}
109
109
  | {|
110
- configKey?: string,
110
+ readTracking?: boolean,
111
111
  |},
112
112
  ): Promise<?ConfigResultWithFilePath<T>> {
113
113
  return this.#inner.getConfigFrom(searchPath, filePaths, options);
@@ -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,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
+ });
@@ -12,7 +12,10 @@ import type {
12
12
  ConfigRequestResult,
13
13
  } from '../../src/requests/ConfigRequest';
14
14
  import type {RunAPI} from '../../src/RequestTracker';
15
- import {runConfigRequest} from '../../src/requests/ConfigRequest';
15
+ import {
16
+ getValueAtPath,
17
+ runConfigRequest,
18
+ } from '../../src/requests/ConfigRequest';
16
19
  import {toProjectPath} from '../../src/projectPath';
17
20
 
18
21
  // $FlowFixMe unclear-type forgive me
@@ -228,7 +231,7 @@ describe('ConfigRequest tests', () => {
228
231
  ...baseRequest,
229
232
  invalidateOnConfigKeyChange: [
230
233
  {
231
- configKey: 'key1',
234
+ configKey: ['key1'],
232
235
  filePath: toProjectPath(projectRoot, 'config.json'),
233
236
  },
234
237
  ],
@@ -244,8 +247,189 @@ describe('ConfigRequest tests', () => {
244
247
  const call = mockCast(mockRunApi.invalidateOnConfigKeyChange).getCall(0);
245
248
  assert.deepEqual(
246
249
  call.args,
247
- ['config.json', 'key1', hashString('"value1"')],
250
+ ['config.json', ['key1'], hashString('"value1"')],
248
251
  'Invalidate was called for key1',
249
252
  );
250
253
  });
251
254
  });
255
+
256
+ describe('getValueAtPath', () => {
257
+ it('can get a key from an object', () => {
258
+ const obj = {a: {b: {c: 'd'}}};
259
+ assert.equal(getValueAtPath(obj, ['a', 'b', 'c']), 'd');
260
+ });
261
+
262
+ it('returns the original object when key array is empty', () => {
263
+ const obj = {a: 1, b: 2};
264
+ assert.deepEqual(getValueAtPath(obj, []), obj);
265
+ });
266
+
267
+ it('can access single-level properties', () => {
268
+ const obj = {name: 'test', age: 25};
269
+ assert.equal(getValueAtPath(obj, ['name']), 'test');
270
+ assert.equal(getValueAtPath(obj, ['age']), 25);
271
+ });
272
+
273
+ it('returns undefined for non-existent keys', () => {
274
+ const obj = {a: {b: 'value'}};
275
+ assert.equal(getValueAtPath(obj, ['nonexistent']), undefined);
276
+ assert.equal(getValueAtPath(obj, ['a', 'nonexistent']), undefined);
277
+ assert.equal(getValueAtPath(obj, ['a', 'b', 'nonexistent']), undefined);
278
+ });
279
+
280
+ it('handles null and undefined values in the path', () => {
281
+ const obj = {a: null, b: {c: undefined}};
282
+ assert.equal(getValueAtPath(obj, ['a']), null);
283
+ assert.equal(getValueAtPath(obj, ['b', 'c']), undefined);
284
+ });
285
+
286
+ it('does not throw when trying to access property of null', () => {
287
+ const obj = {a: null};
288
+ assert.equal(getValueAtPath(obj, ['a', 'b']), undefined);
289
+ });
290
+
291
+ it('does not throw when trying to access property of undefined', () => {
292
+ const obj = {a: undefined};
293
+ assert.equal(getValueAtPath(obj, ['a', 'b']), undefined);
294
+ });
295
+
296
+ it('can access nested arrays and objects', () => {
297
+ const obj = {
298
+ data: [
299
+ {name: 'item1', props: {color: 'red'}},
300
+ {name: 'item2', props: {color: 'blue'}},
301
+ ],
302
+ };
303
+ assert.equal(getValueAtPath(obj, ['data', '0', 'name']), 'item1');
304
+ assert.equal(getValueAtPath(obj, ['data', '1', 'props', 'color']), 'blue');
305
+ });
306
+
307
+ it('handles numeric keys as strings', () => {
308
+ const obj = {'0': 'first', '1': {nested: 'value'}};
309
+ assert.equal(getValueAtPath(obj, ['0']), 'first');
310
+ assert.equal(getValueAtPath(obj, ['1', 'nested']), 'value');
311
+ });
312
+
313
+ it('handles keys with special characters', () => {
314
+ const obj = {
315
+ 'key-with-dashes': 'value1',
316
+ 'key.with.dots': {
317
+ 'nested-key': 'value2',
318
+ },
319
+ 'key with spaces': 'value3',
320
+ '@special$chars#': 'value4',
321
+ };
322
+ assert.equal(getValueAtPath(obj, ['key-with-dashes']), 'value1');
323
+ assert.equal(
324
+ getValueAtPath(obj, ['key.with.dots', 'nested-key']),
325
+ 'value2',
326
+ );
327
+ assert.equal(getValueAtPath(obj, ['key with spaces']), 'value3');
328
+ assert.equal(getValueAtPath(obj, ['@special$chars#']), 'value4');
329
+ });
330
+
331
+ it('handles falsy values correctly', () => {
332
+ const obj = {
333
+ zero: 0,
334
+ false: false,
335
+ emptyString: '',
336
+ nullValue: null,
337
+ undefinedValue: undefined,
338
+ nested: {
339
+ zero: 0,
340
+ false: false,
341
+ },
342
+ };
343
+ assert.equal(getValueAtPath(obj, ['zero']), 0);
344
+ assert.equal(getValueAtPath(obj, ['false']), false);
345
+ assert.equal(getValueAtPath(obj, ['emptyString']), '');
346
+ assert.equal(getValueAtPath(obj, ['nullValue']), null);
347
+ assert.equal(getValueAtPath(obj, ['undefinedValue']), undefined);
348
+ assert.equal(getValueAtPath(obj, ['nested', 'zero']), 0);
349
+ assert.equal(getValueAtPath(obj, ['nested', 'false']), false);
350
+ });
351
+
352
+ it('handles deep nesting', () => {
353
+ const obj = {
354
+ level1: {
355
+ level2: {
356
+ level3: {
357
+ level4: {
358
+ level5: {
359
+ deepValue: 'found',
360
+ },
361
+ },
362
+ },
363
+ },
364
+ },
365
+ };
366
+ assert.equal(
367
+ getValueAtPath(obj, [
368
+ 'level1',
369
+ 'level2',
370
+ 'level3',
371
+ 'level4',
372
+ 'level5',
373
+ 'deepValue',
374
+ ]),
375
+ 'found',
376
+ );
377
+ });
378
+
379
+ it('handles Date objects', () => {
380
+ const date = new Date('2023-01-01');
381
+ const obj = {
382
+ timestamp: date,
383
+ nested: {
384
+ date: date,
385
+ },
386
+ };
387
+ assert.equal(getValueAtPath(obj, ['timestamp']), date);
388
+ assert.equal(getValueAtPath(obj, ['nested', 'date']), date);
389
+ });
390
+
391
+ it('handles complex nested structures with mixed types', () => {
392
+ const obj = {
393
+ users: [
394
+ {
395
+ id: 1,
396
+ profile: {
397
+ settings: {
398
+ theme: 'dark',
399
+ notifications: true,
400
+ },
401
+ },
402
+ },
403
+ {
404
+ id: 2,
405
+ profile: {
406
+ settings: {
407
+ theme: 'light',
408
+ notifications: false,
409
+ },
410
+ },
411
+ },
412
+ ],
413
+ config: {
414
+ version: '1.0.0',
415
+ features: ['feature1', 'feature2'],
416
+ },
417
+ };
418
+
419
+ assert.equal(
420
+ getValueAtPath(obj, ['users', '0', 'profile', 'settings', 'theme']),
421
+ 'dark',
422
+ );
423
+ assert.equal(
424
+ getValueAtPath(obj, [
425
+ 'users',
426
+ '1',
427
+ 'profile',
428
+ 'settings',
429
+ 'notifications',
430
+ ]),
431
+ false,
432
+ );
433
+ assert.equal(getValueAtPath(obj, ['config', 'features', '0']), 'feature1');
434
+ });
435
+ });