Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions lib/core/runtime/AvenxComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand All @@ -454,6 +456,8 @@ export class AvenxComponent {
if (this.#isMounted) {
this.#triggerLifecycle('onUpdate');
}

this.__notifyInjectingChildren();
} finally {
this.#isUpdating = false;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions test/integration/provide_inject.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<div>Child Theme: {{ theme }}</div>', {}, props);
}
runUpdate() {
reactiveChildRenders++;
super.runUpdate();
}
}
ReactiveChildComp.inject = ['theme'];

class ReactiveParentPage extends AvenxPage {
constructor(bridges, registry) {
super(
{ parentTheme: 'initial-dark' },
{},
bridges,
'<div>' + ' <ReactiveChildComp data-avenx-comp="ReactiveChildComp"></ReactiveChildComp>' + '</div>',
{},
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) {
Expand Down
Loading