diff --git a/src/core/outlet_properties.ts b/src/core/outlet_properties.ts index 704f6e33..4af436a4 100644 --- a/src/core/outlet_properties.ts +++ b/src/core/outlet_properties.ts @@ -10,26 +10,42 @@ export function OutletPropertiesBlessing(constructor: Constructor) { }, {} as PropertyDescriptorMap) } +function getOutletController(controller: Controller, element: Element, identifier: string) { + return controller.application.getControllerForElementAndIdentifier(element, identifier) +} + +function getControllerAndEnsureConnectedScope(controller: Controller, element: Element, outletName: string) { + let outletController = getOutletController(controller, element, outletName) + if (outletController) return outletController + + controller.application.router.proposeToConnectScopeForElementAndIdentifier(element, outletName) + + outletController = getOutletController(controller, element, outletName) + if (outletController) return outletController +} + function propertiesForOutletDefinition(name: string) { const camelizedName = namespaceCamelize(name) return { [`${camelizedName}Outlet`]: { get(this: Controller) { - const outlet = this.outlets.find(name) - - if (outlet) { - const outletController = this.application.getControllerForElementAndIdentifier(outlet, name) - if (outletController) { - return outletController - } else { - throw new Error( - `Missing "${this.application.schema.controllerAttribute}=${name}" attribute on outlet element for "${this.identifier}" controller` - ) - } + const outletElement = this.outlets.find(name) + const selector = this.outlets.getSelectorForOutletName(name) + + if (outletElement) { + const outletController = getControllerAndEnsureConnectedScope(this, outletElement, name) + + if (outletController) return outletController + + throw new Error( + `The provided outlet element is missing an outlet controller "${name}" instance for host controller "${this.identifier}"` + ) } - throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`) + throw new Error( + `Missing outlet element "${name}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${selector}".` + ) }, }, @@ -39,16 +55,15 @@ function propertiesForOutletDefinition(name: string) { if (outlets.length > 0) { return outlets - .map((outlet: Element) => { - const controller = this.application.getControllerForElementAndIdentifier(outlet, name) - if (controller) { - return controller - } else { - console.warn( - `The provided outlet element is missing the outlet controller "${name}" for "${this.identifier}"`, - outlet - ) - } + .map((outletElement: Element) => { + const outletController = getControllerAndEnsureConnectedScope(this, outletElement, name) + + if (outletController) return outletController + + console.warn( + `The provided outlet element is missing an outlet controller "${name}" instance for host controller "${this.identifier}"`, + outletElement + ) }) .filter((controller) => controller) as Controller[] } @@ -59,11 +74,15 @@ function propertiesForOutletDefinition(name: string) { [`${camelizedName}OutletElement`]: { get(this: Controller) { - const outlet = this.outlets.find(name) - if (outlet) { - return outlet + const outletElement = this.outlets.find(name) + const selector = this.outlets.getSelectorForOutletName(name) + + if (outletElement) { + return outletElement } else { - throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`) + throw new Error( + `Missing outlet element "${name}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${selector}".` + ) } }, }, diff --git a/src/core/router.ts b/src/core/router.ts index 6b63318d..d0d40a84 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -75,6 +75,16 @@ export class Router implements ScopeObserverDelegate { } } + proposeToConnectScopeForElementAndIdentifier(element: Element, identifier: string) { + const scope = this.scopeObserver.parseValueForElementAndIdentifier(element, identifier) + + if (scope) { + this.scopeObserver.elementMatchedValue(scope.element, scope) + } else { + console.error(`Couldn't find or create scope for identifier: "${identifier}" and element:`, element) + } + } + // Error handler delegate handleError(error: Error, message: string, detail: any) { diff --git a/src/core/scope_observer.ts b/src/core/scope_observer.ts index 6ddcd0ef..0349a24b 100644 --- a/src/core/scope_observer.ts +++ b/src/core/scope_observer.ts @@ -42,6 +42,10 @@ export class ScopeObserver implements ValueListObserverDelegate { parseValueForToken(token: Token): Scope | undefined { const { element, content: identifier } = token + return this.parseValueForElementAndIdentifier(element, identifier) + } + + parseValueForElementAndIdentifier(element: Element, identifier: string): Scope | undefined { const scopesByIdentifier = this.fetchScopesByIdentifierForElement(element) let scope = scopesByIdentifier.get(identifier) diff --git a/src/tests/controllers/outlet_controller.ts b/src/tests/controllers/outlet_controller.ts index 261a4734..b382f80c 100644 --- a/src/tests/controllers/outlet_controller.ts +++ b/src/tests/controllers/outlet_controller.ts @@ -19,6 +19,7 @@ export class OutletController extends BaseOutletController { alphaOutletDisconnectedCallCount: Number, betaOutletConnectedCallCount: Number, betaOutletDisconnectedCallCount: Number, + betaOutletsInConnect: Number, gammaOutletConnectedCallCount: Number, gammaOutletDisconnectedCallCount: Number, namespacedEpsilonOutletConnectedCallCount: Number, @@ -46,11 +47,16 @@ export class OutletController extends BaseOutletController { alphaOutletDisconnectedCallCountValue = 0 betaOutletConnectedCallCountValue = 0 betaOutletDisconnectedCallCountValue = 0 + betaOutletsInConnectValue = 0 gammaOutletConnectedCallCountValue = 0 gammaOutletDisconnectedCallCountValue = 0 namespacedEpsilonOutletConnectedCallCountValue = 0 namespacedEpsilonOutletDisconnectedCallCountValue = 0 + connect() { + this.betaOutletsInConnectValue = this.betaOutlets.length + } + alphaOutletConnected(_outlet: Controller, element: Element) { if (this.hasConnectedClass) element.classList.add(this.connectedClass) this.alphaOutletConnectedCallCountValue++ diff --git a/src/tests/modules/core/outlet_order_tests.ts b/src/tests/modules/core/outlet_order_tests.ts new file mode 100644 index 00000000..5d8bfcf4 --- /dev/null +++ b/src/tests/modules/core/outlet_order_tests.ts @@ -0,0 +1,45 @@ +import { ControllerTestCase } from "../../cases/controller_test_case" +import { OutletController } from "../../controllers/outlet_controller" + +const connectOrder: string[] = [] + +class OutletOrderController extends OutletController { + connect() { + connectOrder.push(`${this.identifier}-${this.element.id}-start`) + super.connect() + connectOrder.push(`${this.identifier}-${this.element.id}-end`) + } +} + +export default class OutletOrderTests extends ControllerTestCase(OutletOrderController) { + fixtureHTML = ` +
Search
+
Beta
+
Beta
+
Beta
+ ` + + get identifiers() { + return ["alpha", "beta"] + } + + async "test can access outlets in connect() even if they are referenced before they are connected"() { + this.assert.equal(this.controller.betaOutletsInConnectValue, 3) + + this.controller.betaOutlets.forEach((outlet) => { + this.assert.equal(outlet.identifier, "beta") + this.assert.equal(Array.from(outlet.element.classList.values()), "beta") + }) + + this.assert.deepEqual(connectOrder, [ + "alpha-alpha1-start", + "beta-beta-1-start", + "beta-beta-1-end", + "beta-beta-2-start", + "beta-beta-2-end", + "beta-beta-3-start", + "beta-beta-3-end", + "alpha-alpha1-end", + ]) + } +}