assign-gingerly 0.0.73 → 0.0.75

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/assignFeatures.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * properties installed on the class prototype.
10
10
  */
11
11
  import { parseWithAttrs } from './parseWithAttrs.js';
12
+ import { isAsyncSpawn } from './utils/isAsyncSpawn.js';
13
+ import { findClassPrototypeInPath } from './utils/findClassPrototypeInPath.js';
12
14
  /**
13
15
  * WeakMap storing per-instance feature caches.
14
16
  * Outer key: the instance (element or other object).
@@ -44,69 +46,79 @@ export class FeaturesRegistry {
44
46
  */
45
47
  const RAW_INIT_VALS = Symbol('rawInitVals');
46
48
  /**
47
- * Sentinel symbol to mark stored values as error state from failed async spawn.
49
+ * Cache for resolved feature spawns.
50
+ * Key: target constructor, Value: Map<featureKey, resolvedConstructor>
48
51
  */
49
- const FEATURE_ERROR = Symbol('featureError');
50
- /**
51
- * WeakMap storing pending Promises for async feature resolution.
52
- * Outer key: the instance. Inner map: feature key -> { promise, resolve, reject }.
53
- */
54
- const pendingFeatures = new WeakMap();
55
- /**
56
- * Resolves the whenFeatureReady method name from lifecycleKeys config.
57
- * Returns undefined if lifecycleKeys is not set.
58
- */
59
- function resolveWhenFeatureReadyName(lifecycleKeys) {
60
- if (lifecycleKeys === undefined)
61
- return undefined;
62
- if (lifecycleKeys === true)
63
- return 'whenFeatureReady';
64
- return lifecycleKeys.whenFeatureReady || 'whenFeatureReady';
52
+ export const resolvedSpawnCache = new WeakMap();
53
+ function getOrCreateClassCache(ctr) {
54
+ let classCache = resolvedSpawnCache.get(ctr);
55
+ if (!classCache) {
56
+ classCache = new Map();
57
+ resolvedSpawnCache.set(ctr, classCache);
58
+ }
59
+ return classCache;
65
60
  }
66
61
  /**
67
- * Installs the whenFeatureReady method on the constructor prototype if not already present.
62
+ * Resolves a spawn reference (constructor, async function, or import-path string).
68
63
  */
69
- function installWhenFeatureReadyMethod(ctr, methodName) {
70
- // Only install once per class
71
- if (Object.getOwnPropertyDescriptor(ctr.prototype, methodName))
72
- return;
73
- Object.defineProperty(ctr.prototype, methodName, {
74
- value: function (featureKey) {
75
- // Trigger the getter (starts async resolution if needed, or returns sync instance)
76
- const current = this[featureKey];
77
- // Check if there's a pending async resolution for this instance + key
78
- const pending = pendingFeatures.get(this)?.get(featureKey);
79
- if (pending) {
80
- return pending.promise;
81
- }
82
- // No pending — feature is already resolved (sync or async already completed)
83
- return Promise.resolve(current);
84
- },
85
- writable: true,
86
- enumerable: false,
87
- configurable: true
88
- });
64
+ async function resolveSpawn(spawnish) {
65
+ if (!spawnish)
66
+ return undefined;
67
+ if (typeof spawnish === 'string') {
68
+ return await findClassPrototypeInPath(spawnish);
69
+ }
70
+ if (isAsyncSpawn(spawnish)) {
71
+ return await spawnish();
72
+ }
73
+ return spawnish;
89
74
  }
90
75
  /**
91
- * Determines if a function is an async spawner (returns a Promise<Constructor>)
92
- * rather than a synchronous constructor.
93
- *
94
- * Heuristic:
95
- * - AsyncFunction (async () => ...) → async spawner
96
- * - Arrow function (no .prototype) → async spawner (assumed to return Promise<Constructor>)
97
- * - Class or function declaration (has .prototype) → synchronous constructor
76
+ * Returns true if resolving this spawn reference requires awaiting.
98
77
  */
99
- function isAsyncSpawn(fn) {
100
- if (typeof fn !== 'function')
101
- return false;
102
- // Explicit async function
103
- if (fn.constructor.name === 'AsyncFunction')
78
+ function isAsyncResolution(spawnish) {
79
+ if (typeof spawnish === 'string')
104
80
  return true;
105
- // Arrow function or non-constructor function (no .prototype)
106
- if (fn.prototype === undefined)
81
+ if (isAsyncSpawn(spawnish))
107
82
  return true;
108
83
  return false;
109
84
  }
85
+ /**
86
+ * Resolves all configured spawns for the target constructor and caches them.
87
+ * Returns a Promise if any resolution is async, otherwise undefined.
88
+ */
89
+ function resolveAndCacheSpawns(ctr, features, supportedFeatures) {
90
+ let hasAsync = false;
91
+ const asyncResolutions = [];
92
+ for (const key of Object.keys(features)) {
93
+ const classCache = getOrCreateClassCache(ctr);
94
+ if (classCache.has(key))
95
+ continue;
96
+ const featureConfig = features[key];
97
+ let spawnish = featureConfig.spawn;
98
+ if (spawnish === undefined) {
99
+ const optIn = supportedFeatures[key];
100
+ spawnish = optIn?.fallbackSpawn;
101
+ }
102
+ if (!spawnish) {
103
+ classCache.set(key, undefined);
104
+ continue;
105
+ }
106
+ if (isAsyncResolution(spawnish)) {
107
+ hasAsync = true;
108
+ asyncResolutions.push((async () => {
109
+ const resolved = await resolveSpawn(spawnish);
110
+ classCache.set(key, resolved);
111
+ })());
112
+ }
113
+ else {
114
+ classCache.set(key, spawnish);
115
+ }
116
+ }
117
+ if (hasAsync) {
118
+ return Promise.all(asyncResolutions).then(() => undefined);
119
+ }
120
+ return undefined;
121
+ }
110
122
  /**
111
123
  * Installs a getter/setter pair on the constructor's prototype for the given feature key.
112
124
  *
@@ -129,10 +141,6 @@ function installFeatureGetter(ctr, key, featuresRegistry) {
129
141
  featureStorage.set(this, storage);
130
142
  }
131
143
  const stored = storage.get(key);
132
- // Check for error state from failed async spawn
133
- if (stored && typeof stored === 'object' && FEATURE_ERROR in stored) {
134
- throw stored[FEATURE_ERROR];
135
- }
136
144
  // If already spawned (not a raw sentinel, not undefined), return it
137
145
  if (stored !== undefined && !(stored && typeof stored === 'object' && RAW_INIT_VALS in stored)) {
138
146
  return stored;
@@ -158,17 +166,17 @@ function installFeatureGetter(ctr, key, featuresRegistry) {
158
166
  if (!injection) {
159
167
  throw new Error(`assignFeatures: no injection found for feature "${key}"`);
160
168
  }
161
- // Resolve spawn: injection.spawn takes priority, then fallbackSpawn
169
+ // Resolve spawn from the cache populated before getters were installed
170
+ const spawns = resolvedSpawnCache.get(ctr);
171
+ const resolvedSpawn = spawns?.get(key);
172
+ if (!resolvedSpawn) {
173
+ throw new Error(`assignFeatures: no spawn implementation found for feature "${key}"`);
174
+ }
162
175
  const supportedFeatures = ctr.supportedFeatures;
163
176
  const optIn = supportedFeatures?.[key];
164
177
  if (!optIn) {
165
178
  throw new Error(`assignFeatures: "${key}" not in static supportedFeatures`);
166
179
  }
167
- const SpawnClass = injection.spawn || optIn.fallbackSpawn;
168
- if (!SpawnClass) {
169
- throw new Error(`assignFeatures: no spawn implementation found for feature "${key}". ` +
170
- `Provide spawn in assignFeatures() or fallbackSpawn in supportedFeatures.`);
171
- }
172
180
  // Build the spawn context
173
181
  const shared = optIn.getSharedContext?.(this);
174
182
  const ctx = {
@@ -196,103 +204,16 @@ function installFeatureGetter(ctr, key, featuresRegistry) {
196
204
  ? { ...attrInitVals, ...initVals }
197
205
  : attrInitVals;
198
206
  }
199
- if (isAsyncSpawn(SpawnClass)) {
200
- // Async path: SpawnClass is a function that returns Promise<Constructor>
201
- const placeholder = initVals && typeof initVals === 'object' ? initVals : {};
202
- storage.set(key, placeholder);
203
- // Capture host element reference for the async callback
204
- const hostElement = this;
205
- // Create a pending Promise for whenFeatureReady consumers
206
- let pendingMap = pendingFeatures.get(hostElement);
207
- if (!pendingMap) {
208
- pendingMap = new Map();
209
- pendingFeatures.set(hostElement, pendingMap);
207
+ // Synchronous path: SpawnClass is a constructor
208
+ const instance = new resolvedSpawn(this, ctx, initVals);
209
+ // Validate shape if configured
210
+ if (optIn.validateShape) {
211
+ if (!optIn.validateShape(instance)) {
212
+ throw new Error(`assignFeatures: spawned instance for "${key}" failed shape validation`);
210
213
  }
211
- let resolvePending;
212
- let rejectPending;
213
- const promise = new Promise((resolve, reject) => {
214
- resolvePending = resolve;
215
- rejectPending = reject;
216
- });
217
- pendingMap.set(key, { promise, resolve: resolvePending, reject: rejectPending });
218
- // Kick off async resolution
219
- SpawnClass().then((ResolvedClass) => {
220
- // Mutate injection so future getter calls see the resolved constructor
221
- injection.spawn = ResolvedClass;
222
- // Get the current placeholder (may have accumulated properties via assignGingerly)
223
- const currentStorage = featureStorage.get(hostElement);
224
- const currentPlaceholder = currentStorage?.get(key);
225
- // Don't upgrade if an error was stored or if already upgraded
226
- if (!currentPlaceholder || (typeof currentPlaceholder === 'object' && FEATURE_ERROR in currentPlaceholder)) {
227
- return;
228
- }
229
- // Parse attributes at resolution time (element should be in DOM by now)
230
- let asyncAttrInitVals = undefined;
231
- if (injection.withAttrs && hostElement instanceof Element) {
232
- try {
233
- asyncAttrInitVals = parseWithAttrs(hostElement, injection.withAttrs, true // always unprefixed for features
234
- );
235
- }
236
- catch (e) {
237
- // Non-fatal: log and continue with placeholder as initVals
238
- console.error('Error parsing feature attributes during async resolution:', e);
239
- }
240
- }
241
- // Merge: attributes are base, placeholder (programmatic) overrides
242
- const asyncInitVals = asyncAttrInitVals
243
- ? { ...asyncAttrInitVals, ...currentPlaceholder }
244
- : currentPlaceholder;
245
- // Instantiate the real class with merged initVals
246
- const realCtx = {
247
- key,
248
- optIn,
249
- injection,
250
- featuresRegistry: fr,
251
- shared: optIn.getSharedContext?.(hostElement)
252
- };
253
- const instance = new ResolvedClass(hostElement, realCtx, asyncInitVals);
254
- // Validate shape if configured
255
- if (optIn.validateShape) {
256
- if (!optIn.validateShape(instance)) {
257
- const error = new Error(`assignFeatures: spawned instance for "${key}" failed shape validation`);
258
- error.placeholder = currentPlaceholder;
259
- currentStorage.set(key, { [FEATURE_ERROR]: error });
260
- rejectPending(error);
261
- pendingMap.delete(key);
262
- return;
263
- }
264
- }
265
- // Replace placeholder with real instance
266
- currentStorage.set(key, instance);
267
- // Resolve the pending Promise and clean up
268
- resolvePending(instance);
269
- pendingMap.delete(key);
270
- }).catch((err) => {
271
- // Store error state — getter will throw on next access
272
- const currentStorage = featureStorage.get(hostElement);
273
- const currentPlaceholder = currentStorage?.get(key);
274
- const error = new Error(`assignFeatures: async spawn for "${key}" failed: ${err.message}`);
275
- error.placeholder = currentPlaceholder;
276
- error.cause = err;
277
- currentStorage?.set(key, { [FEATURE_ERROR]: error });
278
- // Reject the pending Promise and clean up
279
- rejectPending(error);
280
- pendingMap.delete(key);
281
- });
282
- return placeholder;
283
- }
284
- else {
285
- // Synchronous path: SpawnClass is a constructor
286
- const instance = new SpawnClass(this, ctx, initVals);
287
- // Validate shape if configured
288
- if (optIn.validateShape) {
289
- if (!optIn.validateShape(instance)) {
290
- throw new Error(`assignFeatures: spawned instance for "${key}" failed shape validation`);
291
- }
292
- }
293
- storage.set(key, instance);
294
- return instance;
295
214
  }
215
+ storage.set(key, instance);
216
+ return instance;
296
217
  },
297
218
  enumerable: true,
298
219
  configurable: false
@@ -350,10 +271,9 @@ function installCallbackForwarding(ctr, key, callbacks) {
350
271
  for (const featureKey of keys) {
351
272
  // Access the getter (triggers lazy spawn on first connectedCallback)
352
273
  const feature = this[featureKey];
353
- // Only forward if it's a real instance (not a placeholder or error)
274
+ // Only forward if it's a real instance
354
275
  if (feature && typeof feature === 'object' &&
355
- typeof feature[callbackName] === 'function' &&
356
- !(FEATURE_ERROR in feature)) {
276
+ typeof feature[callbackName] === 'function') {
357
277
  feature[callbackName](...args);
358
278
  }
359
279
  }
@@ -368,9 +288,66 @@ function installCallbackForwarding(ctr, key, callbacks) {
368
288
  featureKeys.add(key);
369
289
  }
370
290
  }
291
+ function installOneFeature(ctr, key, features, supportedFeatures, featuresRegistry) {
292
+ // 1. Confirm the key is opted-in via supportedFeatures
293
+ if (!(key in supportedFeatures)) {
294
+ throw new Error(`assignFeatures: "${key}" is not declared in ${ctr.name || 'constructor'}.supportedFeatures`);
295
+ }
296
+ // 2. Check that the prototype doesn't already have this property defined
297
+ const existingDescriptor = Object.getOwnPropertyDescriptor(ctr.prototype, key);
298
+ if (existingDescriptor) {
299
+ throw new Error(`assignFeatures: "${key}" already exists on ${ctr.name || 'constructor'}.prototype`);
300
+ }
301
+ // 3. Check that this key hasn't already been registered for this constructor
302
+ if (featuresRegistry.hasKey(ctr, key)) {
303
+ throw new Error(`assignFeatures: "${key}" has already been assigned for ${ctr.name || 'constructor'}`);
304
+ }
305
+ // 4. Register the injection
306
+ featuresRegistry.set(ctr, key, features[key]);
307
+ // 5. Install the lazy getter on the prototype
308
+ installFeatureGetter(ctr, key, featuresRegistry);
309
+ // 6. Install callback forwarding if configured (merge author + consumer)
310
+ const featureConfig = features[key];
311
+ const optIn = supportedFeatures[key];
312
+ const authorCallbacks = optIn.callbackForwarding || [];
313
+ const consumerCallbacks = featureConfig.callbackForwarding || [];
314
+ // Union of both (author defaults + consumer additions)
315
+ const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
316
+ if (allCallbacks.length > 0) {
317
+ installCallbackForwarding(ctr, key, allCallbacks);
318
+ }
319
+ // 7. Call static onAssigned if the resolved spawn class defines it
320
+ const classCache = getOrCreateClassCache(ctr);
321
+ const resolvedSpawn = classCache.get(key);
322
+ if (resolvedSpawn && !isAsyncSpawn(resolvedSpawn) &&
323
+ Object.hasOwn(resolvedSpawn, 'onAssigned') &&
324
+ typeof resolvedSpawn.onAssigned === 'function') {
325
+ const result = resolvedSpawn.onAssigned(ctr, featureConfig, key);
326
+ if (result && typeof result.then === 'function') {
327
+ return result;
328
+ }
329
+ }
330
+ return undefined;
331
+ }
332
+ function installAllFeatures(ctr, features, supportedFeatures, featuresRegistry) {
333
+ let asyncResult;
334
+ for (const key of Object.keys(features)) {
335
+ const result = installOneFeature(ctr, key, features, supportedFeatures, featuresRegistry);
336
+ if (result) {
337
+ if (!asyncResult) {
338
+ asyncResult = result.then(() => undefined);
339
+ }
340
+ else {
341
+ asyncResult = asyncResult.then(() => result.then(() => undefined));
342
+ }
343
+ }
344
+ }
345
+ return asyncResult;
346
+ }
371
347
  /**
372
348
  * Core assignFeatures implementation.
373
- * Validates inputs, registers injections, and installs lazy getters.
349
+ * Validates inputs, resolves all configured spawns (async if needed), caches them,
350
+ * and installs lazy getters on the prototype.
374
351
  *
375
352
  * Important: Call assignFeatures BEFORE customElements.define(), or at minimum
376
353
  * before any instances of the element are created. The lazy getters must be on
@@ -386,100 +363,14 @@ export function assignFeatures(ctr, features, featuresRegistry) {
386
363
  if (!supportedFeatures) {
387
364
  throw new Error(`assignFeatures: ${ctr.name || 'constructor'} does not define static supportedFeatures`);
388
365
  }
389
- let hasAsync = false;
390
- async function processFeatures() {
391
- for (const key of Object.keys(features)) {
392
- // 1. Confirm the key is opted-in via supportedFeatures
393
- if (!(key in supportedFeatures)) {
394
- throw new Error(`assignFeatures: "${key}" is not declared in ${ctr.name || 'constructor'}.supportedFeatures`);
395
- }
396
- // 2. Check that the prototype doesn't already have this property defined
397
- const existingDescriptor = Object.getOwnPropertyDescriptor(ctr.prototype, key);
398
- if (existingDescriptor) {
399
- throw new Error(`assignFeatures: "${key}" already exists on ${ctr.name || 'constructor'}.prototype`);
400
- }
401
- // 3. Check that this key hasn't already been registered for this constructor
402
- if (featuresRegistry.hasKey(ctr, key)) {
403
- throw new Error(`assignFeatures: "${key}" has already been assigned for ${ctr.name || 'constructor'}`);
404
- }
405
- // 4. Register the injection
406
- featuresRegistry.set(ctr, key, features[key]);
407
- // 5. Install the lazy getter on the prototype
408
- installFeatureGetter(ctr, key, featuresRegistry);
409
- // 6. Install callback forwarding if configured (merge author + consumer)
410
- const featureConfig = features[key];
411
- const optIn = supportedFeatures[key];
412
- const authorCallbacks = optIn.callbackForwarding || [];
413
- const consumerCallbacks = featureConfig.callbackForwarding || [];
414
- // Union of both (author defaults + consumer additions)
415
- const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
416
- if (allCallbacks.length > 0) {
417
- installCallbackForwarding(ctr, key, allCallbacks);
418
- }
419
- // 7. Call static onAssigned if the spawn class defines it (sequentially awaited)
420
- const SpawnClass = featureConfig.spawn;
421
- if (SpawnClass && !isAsyncSpawn(SpawnClass) &&
422
- Object.hasOwn(SpawnClass, 'onAssigned') &&
423
- typeof SpawnClass.onAssigned === 'function') {
424
- const result = SpawnClass.onAssigned(ctr, featureConfig, key);
425
- if (result && typeof result.then === 'function') {
426
- hasAsync = true;
427
- await result;
428
- }
429
- }
430
- }
431
- // 8. Install whenFeatureReady method if featuresConfig.lifecycleKeys is configured
432
- const featuresConfig = ctr.featuresConfig;
433
- if (featuresConfig?.lifecycleKeys) {
434
- const methodName = resolveWhenFeatureReadyName(featuresConfig.lifecycleKeys);
435
- if (methodName) {
436
- installWhenFeatureReadyMethod(ctr, methodName);
437
- }
438
- }
439
- }
440
- // Check if any feature has an async onAssigned (pre-scan)
441
- for (const key of Object.keys(features)) {
442
- const featureConfig = features[key];
443
- const SpawnClass = featureConfig.spawn;
444
- if (SpawnClass && !isAsyncSpawn(SpawnClass) &&
445
- Object.hasOwn(SpawnClass, 'onAssigned') &&
446
- typeof SpawnClass.onAssigned === 'function') {
447
- // We can't know if it's async without calling it, so always use the async path
448
- // if any onAssigned exists
449
- return processFeatures();
450
- }
366
+ // Resolve all configured spawns before installing getters. This is the single
367
+ // source of truth for spawn resolution; other callers (defineWithFeatures, etc.)
368
+ // delegate to assignFeatures.
369
+ const spawnResolution = resolveAndCacheSpawns(ctr, features, supportedFeatures);
370
+ if (spawnResolution) {
371
+ return spawnResolution.then(() => installAllFeatures(ctr, features, supportedFeatures, featuresRegistry));
451
372
  }
452
- // No onAssigned hooks — run synchronously (inline the logic to avoid the async wrapper)
453
- for (const key of Object.keys(features)) {
454
- if (!(key in supportedFeatures)) {
455
- throw new Error(`assignFeatures: "${key}" is not declared in ${ctr.name || 'constructor'}.supportedFeatures`);
456
- }
457
- const existingDescriptor = Object.getOwnPropertyDescriptor(ctr.prototype, key);
458
- if (existingDescriptor) {
459
- throw new Error(`assignFeatures: "${key}" already exists on ${ctr.name || 'constructor'}.prototype`);
460
- }
461
- if (featuresRegistry.hasKey(ctr, key)) {
462
- throw new Error(`assignFeatures: "${key}" has already been assigned for ${ctr.name || 'constructor'}`);
463
- }
464
- featuresRegistry.set(ctr, key, features[key]);
465
- installFeatureGetter(ctr, key, featuresRegistry);
466
- const featureConfig = features[key];
467
- const optIn = supportedFeatures[key];
468
- const authorCallbacks = optIn.callbackForwarding || [];
469
- const consumerCallbacks = featureConfig.callbackForwarding || [];
470
- const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
471
- if (allCallbacks.length > 0) {
472
- installCallbackForwarding(ctr, key, allCallbacks);
473
- }
474
- }
475
- const featuresConfig = ctr.featuresConfig;
476
- if (featuresConfig?.lifecycleKeys) {
477
- const methodName = resolveWhenFeatureReadyName(featuresConfig.lifecycleKeys);
478
- if (methodName) {
479
- installWhenFeatureReadyMethod(ctr, methodName);
480
- }
481
- }
482
- return undefined;
373
+ return installAllFeatures(ctr, features, supportedFeatures, featuresRegistry);
483
374
  }
484
375
  /**
485
376
  * Captures own-properties that shadow feature getters and stores them as initVals.