diff --git a/lib/core/runtime/AvenxComponent.js b/lib/core/runtime/AvenxComponent.js index 9939db5..766b367 100644 --- a/lib/core/runtime/AvenxComponent.js +++ b/lib/core/runtime/AvenxComponent.js @@ -14,7 +14,7 @@ import { queueJob, nextTick as schedulerNextTick } from '../reactive/scheduler.j import { AvenxSandbox } from '../security/sandbox.js'; import { AvenxWatcher } from '../reactive/watcher.js'; -import { ProxyHandlerFactory } from '../reactive/proxyHandler.js'; +import { ProxyHandlerFactory, RAW_SYMBOL } from '../reactive/proxyHandler.js'; import { processBindDirectives } from '../utils/templateUtils.js'; import { profile } from '../utils/profiler.js'; @@ -436,6 +436,8 @@ export class AvenxComponent { this.#triggerLifecycle('onBeforeUpdate'); } + this.__updateProvidedState(); + this.#patcher.patch(this.#element, this.render(), (expression, slotScope) => this.#resolveTemplateExpression(expression, slotScope), this.$app); // Fill slots with transcluded content. @@ -454,6 +456,8 @@ export class AvenxComponent { if (this.#isMounted) { this.#triggerLifecycle('onUpdate'); } + + this.__notifyInjectingChildren(); } finally { this.#isUpdating = false; } @@ -892,6 +896,13 @@ export class AvenxComponent { this.renderWatcher.teardown(); } + if (this._injectedAncestors) { + for (const { ancestor, key } of this._injectedAncestors) { + ancestor.__unregisterInjectingChild(key, this); + } + this._injectedAncestors = []; + } + if (this._watchers) { for (const watcher of this._watchers) { watcher.teardown(); @@ -1032,6 +1043,18 @@ export class AvenxComponent { ); return undefined; } + + if (!this._injectedAncestors) { + this._injectedAncestors = []; + } + const alreadyRegistered = this._injectedAncestors.some( + (reg) => reg.ancestor === ancestor && reg.key === provideKey + ); + if (!alreadyRegistered) { + ancestor.__registerInjectingChild(provideKey, this); + this._injectedAncestors.push({ ancestor, key: provideKey }); + } + return this.#getAncestorProvidedValue(ancestor, provideKey); }, enumerable: true, @@ -1136,13 +1159,78 @@ export class AvenxComponent { // Create a reactive proxy of the provided object const handlerFactory = new ProxyHandlerFactory({ onChange: () => { - // Do not call scheduleUpdate of this component + this.__notifyInjectingChildren(); }, }); this._providedState = new Proxy(resolved, handlerFactory.create()); } } + /** + * Registers a child component that is injecting a provided key. + * @param {string} key - The provided key. + * @param {AvenxComponent} child - The child component. + */ + __registerInjectingChild(key, child) { + if (!this._injectingChildren) { + this._injectingChildren = new Map(); + } + let children = this._injectingChildren.get(key); + if (!children) { + children = new Set(); + this._injectingChildren.set(key, children); + } + children.add(child); + } + + /** + * Unregisters an injecting child component. + * @param {string} key - The provided key. + * @param {AvenxComponent} child - The child component. + */ + __unregisterInjectingChild(key, child) { + if (this._injectingChildren) { + const children = this._injectingChildren.get(key); + if (children) { + children.delete(child); + } + } + } + + /** + * Notifies all registered injecting child components that a provided value has shifted. + */ + __notifyInjectingChildren() { + if (this._injectingChildren) { + for (const children of this._injectingChildren.values()) { + for (const child of children) { + child.scheduleUpdate(); + } + } + } + } + + /** + * Dynamically re-evaluates the provide option and updates providedState. + */ + __updateProvidedState() { + if (!this._providedState) return; + const provideOption = + this.provide || + (typeof this.constructor.provide === 'function' ? this.constructor.provide() : this.constructor.provide); + if (!provideOption) return; + + const resolved = typeof provideOption === 'function' ? provideOption.call(this) : provideOption; + if (resolved && typeof resolved === 'object' && !Array.isArray(resolved)) { + const rawProvided = this._providedState[RAW_SYMBOL] || this._providedState; + for (const [k, v] of Object.entries(resolved)) { + if (rawProvided[k] !== v) { + this._providedState[k] = v; + } + } + } + } + /** * Retrieves a property or method from the component's scope. * Used for array-based provide to resolve keys dynamically. diff --git a/test/integration/provide_inject.test.js b/test/integration/provide_inject.test.js index af1c993..347be9a 100644 --- a/test/integration/provide_inject.test.js +++ b/test/integration/provide_inject.test.js @@ -627,6 +627,71 @@ global.requestAnimationFrame = (cb) => { console.log(' ✅ Nested scopes and shadowing tests passed!'); + // ------------------------------------------------------------------------- + // Test Case 4: Reactive Provide/Inject Dependency Updates (Primitive Reassignment) + // ------------------------------------------------------------------------- + console.log(' 4. Testing reactive provide / inject dependency updates with primitive reassignment...'); + + let reactiveChildRenders = 0; + + class ReactiveChildComp extends AvenxComponent { + constructor(bridges, props) { + super({}, {}, bridges, '
Child Theme: {{ theme }}
', {}, props); + } + runUpdate() { + reactiveChildRenders++; + super.runUpdate(); + } + } + ReactiveChildComp.inject = ['theme']; + + class ReactiveParentPage extends AvenxPage { + constructor(bridges, registry) { + super( + { parentTheme: 'initial-dark' }, + {}, + bridges, + '
' + ' ' + '
', + {}, + registry, + ); + } + provide() { + return { + theme: this.state.parentTheme, + }; + } + } + + const registry4 = new Map(); + registry4.set('ReactiveChildComp', ReactiveChildComp); + + testRootElement.innerHTML = ''; + const parentPage4 = new ReactiveParentPage({}, registry4); + parentPage4.mount(testRootElement); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const childEl4 = testRootElement.querySelector('[data-avenx-comp="ReactiveChildComp"]'); + assert.ok(childEl4, 'Child component should be mounted in the DOM'); + const childInstance4 = childEl4.__avenx_comp_instance; + assert.ok(childInstance4, 'Child component instance should be cached on element'); + + assert.strictEqual(childInstance4.theme, 'initial-dark', 'Child should inject initial theme'); + assert.strictEqual(childEl4.textContent.trim(), 'Child Theme: initial-dark'); + assert.strictEqual(reactiveChildRenders, 1, 'Child should have rendered once'); + + // Mutate parent state. Since parentTheme shifts, it should trigger child update check + parentPage4.state.parentTheme = 'new-light'; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.strictEqual(childInstance4.theme, 'new-light', 'Child injected theme should update reactively'); + assert.strictEqual(childEl4.textContent.trim(), 'Child Theme: new-light', 'Child template should render updated value'); + assert.strictEqual(reactiveChildRenders, 2, 'Child component should have rendered a second time'); + + console.log(' ✅ Reactive Provide/Inject updates tests passed!'); + console.log('✅ Provide / Inject Integration Tests successfully completed!'); process.exit(0); } catch (error) {