From b8a3719bbf0eaffd1e421637fbfe66fde3634671 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 16 Sep 2026 18:25:30 +0200 Subject: [PATCH 01/13] feat(events): add layout listener overrides --- .../PLAN.md | 282 ++++++++++++++++++ .../investigation.md | 274 +++++++++++++++++ dist/Exceptions/EventHandlerDoesNotExist.php | 20 ++ .../SupportMagewireEvents.php | 68 +++++ portman.config.php | 1 + src/Controller/Playwright/Events.php | 20 ++ src/Magewire/Playwright/Events/Basic.php | 104 +++++++ src/etc/adminhtml/di.xml | 2 +- src/etc/frontend/di.xml | 2 +- .../layout/magewire_playwright_events.xml | 39 +++ .../magewire/playwright/events/basic.phtml | 16 + tests/Playwright/tests/events.spec.js | 105 +++++++ .../SupportMagewireEventsTest.php | 63 ++++ 13 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 .agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md create mode 100644 .agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md create mode 100644 dist/Exceptions/EventHandlerDoesNotExist.php create mode 100644 lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php create mode 100644 src/Controller/Playwright/Events.php create mode 100644 src/Magewire/Playwright/Events/Basic.php create mode 100644 src/view/frontend/layout/magewire_playwright_events.xml create mode 100644 src/view/frontend/templates/tests/magewire/playwright/events/basic.phtml create mode 100644 tests/Playwright/tests/events.spec.js create mode 100644 tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php diff --git a/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md b/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md new file mode 100644 index 00000000..f0c0d283 --- /dev/null +++ b/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md @@ -0,0 +1,282 @@ +# TL;DR + +Introduce selective, per-block feature configuration through `magewire:` layout +arguments, including keyed removal through `false` or `null` tombstones. + +The implemented first slice is event listeners: listener definitions already form +a keyed configuration map and have one server-side resolution point. Loaders are +the next credible candidate. Lazy loading already proves that layout arguments +can override feature attributes. Arbitrary component-property mutation is not +recommended. + +For this slice, "from that point on" means later layout XML declarations before +initial render. Live reconciliation after a component has mounted in the browser +is explicitly deferred. + +# Context + +- Started: 2026-09-15 +- Initial type: Feature +- Current type: Feature +- Status: Listener slice implemented and verified +- Branch: `feat/layout-listener-overrides` + +# Goal + +Allow approved Magewire features to layer per-placement layout configuration over +component and attribute defaults, so later layout XML can add, replace, or remove +keyed entries without leaking removal markers into feature behavior. + +# Tasks + +- [x] Trace the `magewire:` layout-argument grammar and lifecycle +- [x] Verify Magento's array-merge and `null` interpretation behavior +- [x] Inventory active property- and attribute-backed feature configuration +- [x] Rank viable configuration candidates +- [x] Confirm the first feature/property slice +- [x] Define the listener overlay contract and precedence +- [x] Define the static layout-time versus live browser boundary +- [x] Define invalid-value and whole-configuration behavior +- [x] Plan focused unit/integration/browser coverage +- [x] Implement the accepted slice +- [x] Test +- [x] Review + +# Decisions + +## ✅ Use feature-owned adapters, not generic property mutation + +Each participating feature should explicitly consume and validate its own layout +configuration. The mechanism should not reflect into arbitrary protected +properties or assign arbitrary public component state. + +**Reasoning** + +- Listeners and loaders have different normalization and removal rules. +- Public properties are reactive state, not feature configuration. +- Some attribute-backed behavior, especially locked properties, is a security + boundary and must not be weakened by a generic override mechanism. +- Starting with explicit feature consumers keeps the compatibility surface small + and lets a shared abstraction emerge only after multiple proven consumers. + +**Evidence** + +- `src/Component.php:30-49` +- `dist/Features/SupportEvents/SupportEvents.php:49-72` +- `lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php:21-42` +- `dist/Features/SupportLockedProperties/BaseLocked.php:13-18` + +**Owner** + +Magewire + +## ✅ Keep Magewire integration outside Portman-ported files + +The layout-aware listener resolver lives in the external +`SupportMagewireEvents` subclass under `lib/Magewire`. Frontend and adminhtml DI +register that subclass in place of the ported `SupportEvents` feature. + +The Portman source and generated `SupportEvents` class remain unaware of +Magento layout arguments and Magewire resolver methods. + +**Reasoning** + +- Portman-owned files must remain portable representations of upstream + Livewire behavior. +- Magento layout arguments and `magewireResolver()` belong to Magewire's + integration layer. +- An external subclass reuses the complete upstream event lifecycle while + limiting the override to listener-source composition. + +**Evidence** + +- `lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php` +- `src/etc/frontend/di.xml` +- `src/etc/adminhtml/di.xml` + +**Owner** + +Willem + +## ✅ Implement listeners first + +Recommended first slice: layer a layout listener map over class and `#[On]` +listeners, then remove entries whose final layout value is `false` or `null`. + +**Reasoning** + +- It is the motivating use case. +- The feature already centralizes listener lookup for both request authorization + and the initial browser effect. +- Tombstones solve a concrete Magento layout-composition problem. +- The work will establish reusable precedence and normalization semantics before + attempting the more polymorphic loader configuration. + +**Evidence** + +- `dist/Features/SupportEvents/HandlesEvents.php:13-19` +- `dist/Features/SupportEvents/BaseOn.php:16-26` +- `dist/Features/SupportEvents/SupportEvents.php:21-72` + +**Owner** + +Willem + +## ✅ Limit the first slice to final server-side layout composition + +Recommended scope: a later layout XML declaration can override or tombstone an +earlier layout/class/attribute listener before initial browser registration. + +Changing the effective listener set on an already-mounted component should be a +separate follow-up unless it is explicitly required. Current browser handling +only adds listeners from effects and removes them during component cleanup; it +does not reconcile a changing listener set. + +**Evidence** + +- `dist/Features/SupportEvents/SupportEvents.php:40-47` +- `src/view/base/web/js/magewire.esm.js:10631-10654` + +**Owner** + +Willem + +## ✅ Use `magewire:listeners` as the public argument shape + +Recommended starting shape: `magewire:listeners`, an associative array keyed by +event name. String values add or replace a method; `false` and `null` values +remove that event after all sources have been normalized. + +The alternative is a feature group such as `magewire:events:listeners`. Existing +code supports both top-level and grouped argument grammar, so naming should be +settled before implementation. + +Accepted contract: + +- The value must be an associative listener array; a non-array top-level value + is ignored for this first slice. +- String values add or replace handlers. +- Keyed `false` and `null` values remove that event from earlier class, + attribute, or layout sources. +- Whole-map clearing is not included. + +**Evidence** + +- `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:79-133` + +**Owner** + +Willem + +# Investigation + +## VERIFIED + +- Magewire assembles top-level `magewire:*`, legacy `magewire.*`, and grouped + `magewire::` values from block data. Arguments are reassembled from + the reconstructed Magento block on subsequent requests. +- Magento merges repeated block argument arrays with `array_replace_recursive`, + and its `null` argument interpreter preserves an explicit `null`. A later + named listener item can therefore survive layout merging as a tombstone. +- `magewire:component:lazy` already provides a working feature-level precedent: + false-like values disable attribute-driven lazy loading and a layout mode can + override `#[Lazy]`. +- The active listener feature combines class listeners with `#[On]` listeners in + one resolution method. That method is used for both event-call authorization + and the listener names sent to the browser. +- The browser listener implementation is additive. Listener cleanup is tied to + component destruction, not to a later effect containing a smaller set. +- Loader configuration is also property-backed and centralized, but accepts + several shapes (`bool`, `string`, list, or map), making its overlay contract + more complex than listeners. +- Validation `$rules` and `$messages` are configurable maps only on the legacy + `Component\Form` path in the current base component. The ported validation + traits are not enabled on `Component`, so they are a poor first target. +- Pagination data is public component state, while redirects, streams, + notifications, flash messages, and errors are runtime commands/queues. They + are not static configuration-overlay candidates. +- `DataCollection::has()` and `get()` use `isset`/null-coalescing semantics. A + whole argument explicitly set to `null` cannot currently be distinguished from + a missing argument through those accessors. Nested `null` values inside an + argument array do survive and can be inspected. + +## DECIDED + +- "From that point on" primarily refers to Magento layout merge order before a + component is delivered to the browser. +- Layout configuration should have the highest precedence because its purpose is + to customize one component placement without changing the component class. +- Tombstones should be processed before dynamic listener placeholders are + expanded, so a layout can remove the same declarative event key used by a + component or `#[On]` attribute. + +## DEFERRED + +- Whole-map clearing through `magewire:listeners="false|null"`; this slice only + supports keyed tombstones inside the listener array. +- Live, post-mount listener reconciliation; this slice resolves the final + Magento layout composition before initial browser registration. +- Earlier validation of invalid listener methods; values other than strict + `false`/`null` retain the existing failure-on-dispatch behavior. + +# Candidate summary + +| Priority | Feature/property | Fit | Direction | +|---|---|---|---| +| 1 | Events / `$listeners` | Strong keyed map and central resolver | Implemented | +| 2 | Magewire loaders / `$loader` | Useful keyed removals, but polymorphic input | Follow after listener semantics settle | +| 3 | Lazy loading / `#[Lazy]` options | Layout override already exists; `isolate` is the main gap | Small independent extension | +| 4 | Legacy form / `$rules`, `$messages` | Technically mergeable, but legacy and validation-sensitive | Defer | +| — | Pagination / `$paginators` | Reactive state, not configuration | Exclude | +| — | Locked properties | Security boundary | Exclude | +| — | Redirects, streams, notifications, flash messages, errors | Runtime effects or queues | Exclude | + +# Tooling + +- Repository source and tests +- Magento framework layout reader and argument interpreters in the host application +- Git history for Magewire V1 and V3 behavior +- `caveman-explore` for cross-file localization + +# Supporting documents + +- [Candidate investigation](./investigation.md) + +# Implementation + +- Added an external `SupportMagewireEvents` subclass for layout-aware listener + resolution and registered it for frontend and adminhtml areas. +- Kept the Portman source and generated `SupportEvents` class free of + Magewire-specific resolver access. +- Included the upstream `EventHandlerDoesNotExist` exception that the active + server authorization path already references, so rejected tombstoned events + fail with the intended error instead of a missing-class error. +- Listener sources are normalized and layered in this order: component class, + `#[On]` attributes, then `magewire:listeners`. +- Tombstones are resolved before dynamic event placeholders are expanded. +- Added focused unit coverage for normalization, precedence, and tombstones. +- Added a Magento layout and Playwright fixture covering class listeners, + attributes, later layout replacement/removal, browser effects, and real event + dispatch. + +# Verification + +- `mago lint` on all changed PHP source and test files +- `mago format --check` on all changed PHP source and test files +- Portman build with no generated `SupportEvents` diff +- `php-cs-fixer --dry-run` on the generated event-handler exception +- Magento dependency-injection compilation +- PHPUnit: 28 tests passed (full unit suite) +- Playwright events spec: 5 tests passed + +# Change log + +- 2026-09-15: Created the feature work item, completed candidate discovery, and + recommended listeners as the first implementation slice. +- 2026-09-16: Accepted and implemented the `magewire:listeners` slice with + keyed `false`/`null` tombstones, generated output, and focused unit/browser + coverage. +- 2026-09-16: Moved layout-aware event resolution out of the Portman layer into + an externally registered Magewire feature subclass. +- 2026-09-16: Created `feat/layout-listener-overrides` from `main` for draft + review. diff --git a/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md b/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md new file mode 100644 index 00000000..46d1a5f7 --- /dev/null +++ b/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md @@ -0,0 +1,274 @@ +# Layout feature override investigation + +# Question + +Which Magewire component properties or feature settings are suitable for +per-placement `magewire:` layout overrides with additive, replacement, and +`false`/`null` removal semantics, and which should be implemented first? + +# Current argument model + +`MagewireArguments` currently recognizes three forms: + +| Form | Current role | Evidence | +|---|---|---| +| `magewire:` | Top-level component/resolver metadata such as `id`, `name`, and `alias` | `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:79-95`, `lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/LayoutResolver.php:173-192` | +| `magewire::` | Named configuration groups; currently `mount` and `component` are consumed | `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:48-61`, `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:117-135` | +| `magewire.` | Collected into a `public` subset, but no current consumer was found | `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:98-115` | + +The argument collection is reassembled after Magento constructs or reconstructs +the block and before component build hooks run. `magewire:mount:*` is passed into +the component mount lifecycle. Feature code can read other groups directly. + +Evidence: + +- `lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php:84-108` +- `src/Observer/ViewBlockAbstractToHtmlBefore.php:102-108` +- `portman/Livewire/Features/SupportLazyLoading/SupportLazyLoading.php:22-69` + +# Why tombstones work with Magento layout merging + +Magento reads later block/referenceBlock argument declarations into the existing +argument array with `array_replace_recursive`. Named nested items are therefore +replaced by later declarations instead of merely appended. The `xsi:type="null"` +interpreter evaluates to PHP `null`. + +That makes this layout composition viable: + +```xml + + + refreshTotals + + + + + + +``` + +The final block data can contain `['checkout:updated' => null]`; Magewire must +interpret it as removal instead of forwarding it to the feature. + +Evidence from the host Magento application: + +- `vendor/magento/framework/View/Layout/Reader/Block.php:250-260` +- `vendor/magento/framework/View/Layout/Reader/Block.php:356-370` +- `vendor/magento/framework/Data/Argument/Interpreter/ArrayType.php:44-55` +- `vendor/magento/framework/Data/Argument/Interpreter/NullType.php:13-23` + +One framework caveat matters for whole-value `null`: Magewire's +`DataCollection::has()` delegates to `isset()`, and `get()` uses null coalescing. +An explicit top-level `null` is indistinguishable from absence through these +methods. A nested `null` inside a top-level array remains inspectable via the +array value. + +Evidence: + +- `lib/Magewire/Support/DataCollection.php:188-215` +- `lib/Magewire/Support/DataCollection.php:300-305` + +# Candidate assessment + +## 1. Event listeners — strongest first slice + +Sources currently include: + +1. component `$listeners` via `HandlesEvents::getListeners()`; +2. `#[On]` attributes stored as `listenersFromAttributes`; +3. no layout source yet. + +`SupportEvents::getComponentListeners()` is the central merge point. Its result +controls both whether an incoming `__dispatch` event is authorized and which +event names are sent to the browser on mount. + +Evidence: + +- `dist/Features/SupportEvents/HandlesEvents.php:13-25` +- `dist/Features/SupportEvents/BaseOn.php:16-27` +- `dist/Features/SupportEvents/SupportEvents.php:21-72` + +Recommended effective-source order: + +```text +class $listeners + -> #[On] listeners + -> layout listener overlay + -> remove false/null entries + -> expand dynamic placeholders +``` + +All sources should be normalized to `event => method` before the overlay is +applied. This lets a named tombstone remove either an associative listener or a +numeric shorthand such as `['checkout:updated']`. + +Recommended initial shape: + +```xml + + refreshTotals + false + + +``` + +Why first: + +- It is the requested use case. +- It has a single server-side resolution point. +- A false/null entry is currently unsafe: the event key can remain visible while + its method is not callable. +- It establishes normalization, precedence, tombstone, and validation semantics + that can later inform other features. + +### Browser boundary + +For initial layout composition, filtering before dehydration is sufficient: the +browser never receives removed listener names. + +Live post-mount changes are different. `SupportEvents` sends listener names only +while mounting, and the browser registers every received name additively. It +removes handlers only during component cleanup. Supporting a shrinking listener +set after mount would require a reconciliation effect and per-listener cleanup, +not only a PHP merge. + +Evidence: + +- `dist/Features/SupportEvents/SupportEvents.php:40-47` +- `src/view/base/web/js/magewire.esm.js:10631-10654` + +## 2. Magewire loaders — good second slice + +The loader feature reads protected `$loader` through `getLoader()` and emits a +normalized client effect. The client matches configuration against component +method calls and property updates. + +Evidence: + +- `lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php:14-25` +- `lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php:19-43` +- `src/view/base/templates/magewire-features/support-magewire-loaders/support-magewire-loaders.phtml:146-246` + +Per-action layout additions and removals are useful, for example disabling an +inherited loader for one action on one placement. This should follow listeners +because loader configuration is polymorphic: + +- `false` disables all loaders; +- `true` enables general loading behavior; +- a string is a general message; +- a list selects calls/updates; +- an associative map selects and optionally supplies messages. + +A correct overlay must define how a scalar layout value interacts with a class +map, how numeric entries are normalized, and whether false/null entries mean +"remove inherited selector" or "keep a disabled selector." These are more +branches than the listener contract needs. + +## 3. Lazy loading — precedent, not the best new slice + +`magewire:component:lazy` already: + +- opts a component into lazy loading; +- disables `#[Lazy]` with false-like values; +- overrides an attribute's trigger mode per layout placement. + +The main unexposed option is `#[Lazy(isolate: ...)]`. Adding a layout override +for isolation is feasible, but it is a scalar override rather than the requested +keyed add/remove model. + +Evidence: + +- `portman/Livewire/Features/SupportLazyLoading/SupportLazyLoading.php:22-69` +- `portman/Livewire/Features/SupportLazyLoading/BaseLazy.php:7-15` +- `tests/Playwright/tests/lazy-loading.spec.js:121-126` +- `tests/Playwright/tests/lazy-loading.spec.js:377-410` + +## 4. Validation maps — defer + +The legacy `Component\Form` has protected `$rules` and `$messages` maps. They are +technically compatible with named layout overlays and tombstones. + +They should not lead this work because: + +- this is a legacy form path; +- the ported Livewire validation traits are present in generated source but are + not enabled on the current base `Component`; +- removing or replacing validation rules changes a correctness/security policy, + so its contract needs stricter validation and documentation than presentation + configuration. + +Evidence: + +- `src/Component/Form.php:21-68` +- `src/Component.php:30-49` +- `dist/Features/SupportValidation/HandlesValidation.php:25-127` + +## Excluded candidates + +### Pagination + +`$paginators` is public reactive state. Layout defaults can already reach mount +parameters, but generic runtime-state overlays should not be mixed with feature +configuration. + +- `dist/Features/SupportPagination/HandlesPagination.php:14-65` + +### Locked properties + +`#[Locked]` prevents client updates. A generic layout-property override must not +be able to remove or weaken this security boundary. + +- `dist/Features/SupportLockedProperties/BaseLocked.php:13-18` + +### Redirects, streams, notifications, flash messages, and errors + +These are actions, transient stores, effects, or queues produced during a +request. They do not represent declarative component configuration to merge at +block construction time. + +- `dist/Features/SupportRedirects/HandlesRedirects.php:14-24` +- `lib/Magewire/Features/SupportMagewireNotifications/HandlesMagewireNotifications.php:16-23` +- `lib/Magewire/Features/SupportMagentoFlashMessages/HandlesMagewireFlashMessages.php:17-24` +- `lib/MagewireBc/Model/Concern/Error.php:19-29` + +# Recommended first acceptance contract + +If listeners are selected, the first slice should aim for these conditions: + +1. A layout map can add a listener by event name. +2. A later layout declaration can replace the handler for an event name. +3. `false` or `null` removes the named listener regardless of whether it came + from `$listeners`, `#[On]`, or an earlier layout declaration. +4. Removed names are absent from the initial browser `listeners` effect. +5. Removed names fail server-side event authorization exactly like any unknown + listener. +6. Numeric shorthand and associative listener definitions normalize to the same + event-keyed model before layout overlay. +7. Dynamic placeholders are expanded only after layout removal is resolved. +8. Existing components without the layout argument behave identically. +9. Live post-mount reconciliation remains out of scope for this first slice. + +# Testing seams + +The implementation plan should cover: + +- pure listener normalization/overlay cases, including false and null; +- class listener addition, replacement, and removal; +- `#[On]` removal and precedence; +- dynamic placeholder ordering; +- server rejection of a tombstoned listener; +- absence of tombstoned names from initial `wire:effects`; +- Magento layout composition with two declarations for the same named item; +- regression coverage for components without `magewire:listeners`. + +Although `SupportEvents` is Portman-generated under `dist/`, Magewire-specific +integration must remain external to the Portman source. The implementation uses +a `SupportMagewireEvents` subclass under `lib/Magewire` and registers it as the +event feature in frontend and adminhtml DI. The generated upstream event class +remains unchanged. + +Evidence: + +- `lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php` +- `src/etc/frontend/di.xml` +- `src/etc/adminhtml/di.xml` diff --git a/dist/Exceptions/EventHandlerDoesNotExist.php b/dist/Exceptions/EventHandlerDoesNotExist.php new file mode 100644 index 00000000..a604cab1 --- /dev/null +++ b/dist/Exceptions/EventHandlerDoesNotExist.php @@ -0,0 +1,20 @@ +getListeners(), store($component)->get('listenersFromAttributes', []), static::getLayoutListeners($component)); + + return static::replaceDynamicEventNamePlaceholders($listeners, $component); + } + + /** + * Return the per-placement listener overlay declared through the + * `magewire:listeners` layout argument. + */ + protected static function getLayoutListeners($component): array + { + $resolver = $component->magewireResolver(); + + if ($resolver === null) { + return []; + } + + $listeners = $resolver->arguments()->get('listeners', []); + + return is_array($listeners) ? $listeners : []; + } + + /** + * Normalize each source to event => method before layering it. A false or + * null value is a tombstone for the same event in an earlier source. + */ + protected static function mergeListenerSources(array ...$sources): array + { + $listeners = []; + + foreach ($sources as $source) { + foreach ($source as $event => $method) { + $event = is_numeric($event) ? $method : $event; + + if ($method === false || $method === null) { + unset($listeners[$event]); + continue; + } + + $listeners[$event] = $method; + } + } + + return $listeners; + } +} diff --git a/portman.config.php b/portman.config.php index eb17d735..62fe7662 100644 --- a/portman.config.php +++ b/portman.config.php @@ -18,6 +18,7 @@ '!Attributes/{Locked,On,Lazy}.php', 'Drawer/{ImplicitRouteBinding,Regexes}*', 'Exceptions/{Event,Livewire,Root}*', + '!Exceptions/EventHandlerDoesNotExist.php', 'Features/**/*', '!Features/Support{Attributes,Events,LifecycleHooks,Locales,NestingComponents,Redirects,FormObjects,Validation,LockedProperties,Streaming,LazyLoading,MultipleRootElementDetection,Pagination}/**/*', 'Features/SupportEvents/TestsEvents.php', diff --git a/src/Controller/Playwright/Events.php b/src/Controller/Playwright/Events.php new file mode 100644 index 00000000..60c3fcd2 --- /dev/null +++ b/src/Controller/Playwright/Events.php @@ -0,0 +1,20 @@ + 'onClassKept', + 'class:removed' => 'onClassRemoved', + 'class:replaced' => 'onClassOriginal', + 'shorthandListener', + 'dynamic:{scope}' => 'onDynamicListener' + ]; + + public function onClassKept(): void + { + $this->result = 'class-kept'; + } + + public function onClassRemoved(): void + { + $this->result = 'class-removed'; + } + + public function onClassOriginal(): void + { + $this->result = 'class-original'; + } + + public function onClassReplacement(): void + { + $this->result = 'class-replacement'; + } + + public function shorthandListener(): void + { + $this->result = 'shorthand-listener'; + } + + public function onDynamicListener(): void + { + $this->result = 'dynamic-listener'; + } + + #[On('attribute:kept')] + public function onAttributeKept(): void + { + $this->result = 'attribute-kept'; + } + + #[On('attribute:removed')] + public function onAttributeRemoved(): void + { + $this->result = 'attribute-removed'; + } + + #[On('attribute:replaced')] + public function onAttributeOriginal(): void + { + $this->result = 'attribute-original'; + } + + public function onAttributeReplacement(): void + { + $this->result = 'attribute-replacement'; + } + + public function onLayoutAdded(): void + { + $this->result = 'layout-added'; + } + + public function onLayoutOriginal(): void + { + $this->result = 'layout-original'; + } + + public function onLayoutReplacement(): void + { + $this->result = 'layout-replacement'; + } + + public function onLayoutRemoved(): void + { + $this->result = 'layout-removed'; + } +} diff --git a/src/etc/adminhtml/di.xml b/src/etc/adminhtml/di.xml index 86dae71b..40e465c7 100644 --- a/src/etc/adminhtml/di.xml +++ b/src/etc/adminhtml/di.xml @@ -204,7 +204,7 @@ - Magewirephp\Magewire\Features\SupportEvents\SupportEvents + Magewirephp\Magewire\Features\SupportMagewireEvents\SupportMagewireEvents 1600 diff --git a/src/etc/frontend/di.xml b/src/etc/frontend/di.xml index f898e3c9..cc710c7d 100644 --- a/src/etc/frontend/di.xml +++ b/src/etc/frontend/di.xml @@ -192,7 +192,7 @@ - Magewirephp\Magewire\Features\SupportEvents\SupportEvents + Magewirephp\Magewire\Features\SupportMagewireEvents\SupportMagewireEvents 1600 diff --git a/src/view/frontend/layout/magewire_playwright_events.xml b/src/view/frontend/layout/magewire_playwright_events.xml new file mode 100644 index 00000000..eeb13985 --- /dev/null +++ b/src/view/frontend/layout/magewire_playwright_events.xml @@ -0,0 +1,39 @@ + + + + + + + + Magewirephp\Magewire\Magewire\Playwright\Events\Basic + + + onLayoutAdded + onLayoutRemoved + onLayoutOriginal + false + onClassReplacement + + + + onAttributeReplacement + + + + + + + + + + onLayoutReplacement + + + + + diff --git a/src/view/frontend/templates/tests/magewire/playwright/events/basic.phtml b/src/view/frontend/templates/tests/magewire/playwright/events/basic.phtml new file mode 100644 index 00000000..ada404bc --- /dev/null +++ b/src/view/frontend/templates/tests/magewire/playwright/events/basic.phtml @@ -0,0 +1,16 @@ + +
+

escapeHtml($magewire->result) ?>

+
diff --git a/tests/Playwright/tests/events.spec.js b/tests/Playwright/tests/events.spec.js new file mode 100644 index 00000000..5554ec6f --- /dev/null +++ b/tests/Playwright/tests/events.spec.js @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; + +const PATH = '/magewire/playwright/events'; +const ID = 'magewire.playwright.events.basic'; + +function rootTag(html, wireId) { + const escaped = wireId.replace(/[.]/g, '\\.'); + const match = html.match(new RegExp(`<[^>]*\\bwire:id="${escaped}"[^>]*>`)); + return match ? match[0] : null; +} + +function effectsFromTag(tag) { + const match = tag.match(/wire:effects="([^"]*)"/); + if (!match) { + throw new Error('wire:effects attribute not present on root tag'); + } + + return JSON.parse(match[1].replace(/"/g, '"')); +} + +const component = page => page.locator(`[wire\\:id="${ID}"]`); + +async function dispatch(page, event) { + await page.evaluate((name) => window.dispatchEvent(new CustomEvent(name)), event); +} + +test.describe('Magewire Playwright — Events', () => { + test.beforeEach(async ({ page }) => { + const version = Math.floor(Math.random() * 1_000_000); + await page.goto(`${PATH}?v=${version}`); + }); + + test('renders the page with the correct title', async ({ page }) => { + await expect(page.locator('[data-ui-id="page-title-wrapper"]')) + .toHaveText('Magewire / Playwright / Events'); + }); + + test('publishes only the effective listeners in the initial browser effect', async ({ request }) => { + const html = await (await request.get(PATH)).text(); + const tag = rootTag(html, ID); + + expect(tag).toBeTruthy(); + + const listeners = effectsFromTag(tag).listeners; + + expect(listeners).toEqual(expect.arrayContaining([ + 'class:kept', + 'class:replaced', + 'attribute:kept', + 'attribute:replaced', + 'layout:added', + 'layout:replaced-later', + ])); + expect(listeners).not.toEqual(expect.arrayContaining([ + 'class:removed', + 'shorthandListener', + 'dynamic:resolved', + 'attribute:removed', + 'layout:removed-later', + ])); + }); + + test('dispatches kept and layout-added listeners', async ({ page }) => { + const result = component(page).getByTestId('event-result'); + + await dispatch(page, 'class:kept'); + await expect(result).toHaveText('class-kept'); + + await dispatch(page, 'attribute:kept'); + await expect(result).toHaveText('attribute-kept'); + + await dispatch(page, 'layout:added'); + await expect(result).toHaveText('layout-added'); + }); + + test('uses layout handlers for replaced class, attribute, and layout listeners', async ({ page }) => { + const result = component(page).getByTestId('event-result'); + + await dispatch(page, 'class:replaced'); + await expect(result).toHaveText('class-replacement'); + + await dispatch(page, 'attribute:replaced'); + await expect(result).toHaveText('attribute-replacement'); + + await dispatch(page, 'layout:replaced-later'); + await expect(result).toHaveText('layout-replacement'); + }); + + test('rejects a direct server dispatch to a removed listener', async ({ page }) => { + const response = await page.evaluate(({ id, event }) => new Promise((resolve) => { + const release = window.Livewire.hook('request', ({ fail }) => { + fail(({ status, content, preventDefault }) => { + preventDefault(); + release(); + resolve({ status, content }); + }); + }); + + window.Livewire.find(id).call('__dispatch', event, {}); + }), { id: ID, event: 'class:removed' }); + + expect(response.status).toBe(500); + expect(response.content).toContain('Handler for event class:removed does not exist'); + }); +}); diff --git a/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php new file mode 100644 index 00000000..92ea362e --- /dev/null +++ b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php @@ -0,0 +1,63 @@ +mergeListenerSources( + [ + 'shorthandListener', + 'class:kept' => 'onClassKept', + 'shared:event' => 'onClassShared' + ], + [ + 'attribute:kept' => 'onAttributeKept', + 'shared:event' => 'onAttributeShared' + ], + [ + 'layout:added' => 'onLayoutAdded', + 'shared:event' => 'onLayoutShared' + ] + ); + + self::assertSame( + [ + 'shorthandListener' => 'shorthandListener', + 'class:kept' => 'onClassKept', + 'shared:event' => 'onLayoutShared', + 'attribute:kept' => 'onAttributeKept', + 'layout:added' => 'onLayoutAdded' + ], + $listeners + ); + } + + public function test_false_and_null_remove_listeners_from_earlier_sources(): void + { + $listeners = $this->mergeListenerSources([ + 'class:removed' => 'onClassRemoved', + 'class:null-removed' => 'onClassNullRemoved', + 'class:kept' => 'onClassKept' + ], [ + 'class:removed' => false, + 'class:null-removed' => null + ]); + + self::assertSame(['class:kept' => 'onClassKept'], $listeners); + } + + private function mergeListenerSources(array ...$sources): array + { + $method = new ReflectionMethod(SupportMagewireEvents::class, 'mergeListenerSources'); + + return $method->invoke(null, ...$sources); + } +} From 9c9f994dd50a1944cd762cb8cf80f4cdafd4b431 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 16 Sep 2026 18:26:21 +0200 Subject: [PATCH 02/13] docs(plan): record draft pull request --- .agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md b/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md index f0c0d283..f025987a 100644 --- a/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md +++ b/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md @@ -20,6 +20,7 @@ is explicitly deferred. - Current type: Feature - Status: Listener slice implemented and verified - Branch: `feat/layout-listener-overrides` +- Draft PR: #307 # Goal @@ -280,3 +281,4 @@ Willem an externally registered Magewire feature subclass. - 2026-09-16: Created `feat/layout-listener-overrides` from `main` for draft review. +- 2026-09-16: Opened draft pull request #307. From 876f438a46dc2e96917a0ed7e47051aa104b6f5c Mon Sep 17 00:00:00 2001 From: wpoortman <5383956+wpoortman@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:26:53 +0000 Subject: [PATCH 03/13] chore(portman) build --- dist/Exceptions/EventHandlerDoesNotExist.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dist/Exceptions/EventHandlerDoesNotExist.php b/dist/Exceptions/EventHandlerDoesNotExist.php index a604cab1..2f3f6b67 100644 --- a/dist/Exceptions/EventHandlerDoesNotExist.php +++ b/dist/Exceptions/EventHandlerDoesNotExist.php @@ -1,5 +1,4 @@ Date: Thu, 17 Sep 2026 09:56:13 +0200 Subject: [PATCH 04/13] refactor(events): compose layout listener overrides --- .../PLAN.md | 45 ++++--- .../investigation.md | 13 +- .../SupportMagewireEvents.php | 118 +++++++++++++++--- src/etc/adminhtml/di.xml | 7 +- src/etc/frontend/di.xml | 7 +- .../SupportMagewireEventsTest.php | 60 ++++----- 6 files changed, 183 insertions(+), 67 deletions(-) diff --git a/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md b/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md index f025987a..de5ff59e 100644 --- a/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md +++ b/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md @@ -3,10 +3,10 @@ Introduce selective, per-block feature configuration through `magewire:` layout arguments, including keyed removal through `false` or `null` tombstones. -The implemented first slice is event listeners: listener definitions already form -a keyed configuration map and have one server-side resolution point. Loaders are -the next credible candidate. Lazy loading already proves that layout arguments -can override feature attributes. Arbitrary component-property mutation is not +The implemented first slice is event listeners. A standalone Magewire feature +now composes with the ported event feature through ordered lifecycle hooks; it +does not extend, replace, or add Magento knowledge to `SupportEvents`. Loaders +are the next credible candidate. Arbitrary component-property mutation is not recommended. For this slice, "from that point on" means later layout XML declarations before @@ -71,14 +71,17 @@ properties or assign arbitrary public component state. Magewire -## ✅ Keep Magewire integration outside Portman-ported files +## ✅ Compose with the ported event feature instead of replacing it -The layout-aware listener resolver lives in the external -`SupportMagewireEvents` subclass under `lib/Magewire`. Frontend and adminhtml DI -register that subclass in place of the ported `SupportEvents` feature. +The ported `SupportEvents` feature remains registered unchanged. The external +`SupportMagewireEvents` feature is registered separately at a later sort order. +It contributes active layout handlers during `boot()`, rejects tombstoned +dispatches through a pre-call guard, and filters the browser listener effect in +its later `dehydrate()` hook. -The Portman source and generated `SupportEvents` class remain unaware of -Magento layout arguments and Magewire resolver methods. +If the core event feature is disabled, the Magewire feature skips itself. The +Portman source and generated `SupportEvents` class remain unaware of Magento +layout arguments and Magewire resolver methods. **Reasoning** @@ -86,14 +89,20 @@ Magento layout arguments and Magewire resolver methods. Livewire behavior. - Magento layout arguments and `magewireResolver()` belong to Magewire's integration layer. -- An external subclass reuses the complete upstream event lifecycle while - limiting the override to listener-source composition. +- Dehydration alone can change browser subscriptions, but server dispatch + authorization and method resolution happen earlier in `SupportEvents::call()`. +- Ordered composition preserves both server and browser behavior without + replacing the core feature. +- Keeping active layout handlers in the existing attribute-backed listener + source preserves dynamic component `getListeners()` implementations. **Evidence** - `lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php` - `src/etc/frontend/di.xml` - `src/etc/adminhtml/di.xml` +- `dist/ComponentHookRegistry.php:42-75` +- `dist/Features/SupportEvents/SupportEvents.php:21-72` **Owner** @@ -245,10 +254,16 @@ Willem # Implementation -- Added an external `SupportMagewireEvents` subclass for layout-aware listener - resolution and registered it for frontend and adminhtml areas. +- Registered the original ported `SupportEvents` feature unchanged and added an + independent `SupportMagewireEvents` feature immediately after it in frontend + and adminhtml areas. - Kept the Portman source and generated `SupportEvents` class free of Magewire-specific resolver access. +- Active layout handlers are layered into the existing attribute listener + source during `boot()`, before server calls and dehydration. +- Tombstoned server dispatches are rejected by a pre-call guard, while the + later dehydration hook removes tombstoned names from the browser effect. +- The Magewire adapter skips itself when the core event feature is disabled. - Included the upstream `EventHandlerDoesNotExist` exception that the active server authorization path already references, so rejected tombstoned events fail with the intended error instead of a missing-class error. @@ -282,3 +297,5 @@ Willem - 2026-09-16: Created `feat/layout-listener-overrides` from `main` for draft review. - 2026-09-16: Opened draft pull request #307. +- 2026-09-17: Reworked the listener integration into an independently ordered + Magewire feature, restoring the original ported event feature registration. diff --git a/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md b/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md index 46d1a5f7..e5b1ce9c 100644 --- a/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md +++ b/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md @@ -262,10 +262,15 @@ The implementation plan should cover: - regression coverage for components without `magewire:listeners`. Although `SupportEvents` is Portman-generated under `dist/`, Magewire-specific -integration must remain external to the Portman source. The implementation uses -a `SupportMagewireEvents` subclass under `lib/Magewire` and registers it as the -event feature in frontend and adminhtml DI. The generated upstream event class -remains unchanged. +integration must remain external to the Portman source. The implementation +keeps the original event feature registered and adds `SupportMagewireEvents` as +an independent, later-running feature under `lib/Magewire`. + +A dehydration-only overlay would be incomplete: it can replace the browser +effect, but event authorization and method resolution already occur in +`SupportEvents::call()`. The companion feature therefore adds active layout +handlers during `boot()`, rejects tombstoned calls through a pre-call guard, and +uses its later `dehydrate()` hook only for the final browser effect. Evidence: diff --git a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php index b8ab2260..836427b6 100644 --- a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php +++ b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php @@ -11,18 +11,61 @@ namespace Magewirephp\Magewire\Features\SupportMagewireEvents; +use Magewirephp\Magewire\ComponentHook; +use Magewirephp\Magewire\ComponentHookRegistry; +use Magewirephp\Magewire\Exceptions\EventHandlerDoesNotExist; use Magewirephp\Magewire\Features\SupportEvents\SupportEvents; +use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext; -use function Magewirephp\Magewire\invade; +use function Magewirephp\Magewire\before; use function Magewirephp\Magewire\store; -class SupportMagewireEvents extends SupportEvents +/** @mago-expect lint:cyclomatic-complexity */ +class SupportMagewireEvents extends ComponentHook { - public static function getComponentListeners($component) + public static function provide(): void { - $listeners = static::mergeListenerSources(invade($component)->getListeners(), store($component)->get('listenersFromAttributes', []), static::getLayoutListeners($component)); + before('call', static function ($component, $method, $params): void { + if ($method !== '__dispatch') { + return; + } + + $hook = ComponentHookRegistry::getHook($component, self::class); + + if ($hook instanceof self && is_string($params[0] ?? null)) { + $hook->ensureListenerIsNotRemoved($params[0]); + } + }); + } - return static::replaceDynamicEventNamePlaceholders($listeners, $component); + public function skip(): bool + { + return ComponentHookRegistry::getHook($this->component(), SupportEvents::class) === null; + } + + public function boot(): void + { + $component = $this->component(); + $fromAttributes = store($component)->get('listenersFromAttributes', []); + + store($component)->set('listenersFromAttributes', static::applyListenerOverlay($fromAttributes, static::getLayoutListeners($component))); + } + + public function dehydrate(ComponentContext $context): void + { + if (! $context->isMounting() || ! $context->hasEffect('listeners')) { + return; + } + + $removed = $this->getRemovedListenerNames(); + $listeners = array_values(array_filter($context->getEffects()->getData('listeners', []), static fn ($listener) => ! in_array($listener, $removed, true))); + + if ($listeners === []) { + $context->getEffects()->exclude('listeners'); + return; + } + + $context->addEffect('listeners', $listeners); } /** @@ -43,26 +86,65 @@ protected static function getLayoutListeners($component): array } /** - * Normalize each source to event => method before layering it. A false or - * null value is a tombstone for the same event in an earlier source. + * Add layout handlers to the source consumed by SupportEvents. Tombstones + * are enforced by the pre-call guard and the later dehydration hook. */ - protected static function mergeListenerSources(array ...$sources): array + protected static function applyListenerOverlay(array $listeners, array $overlay): array { - $listeners = []; + $listeners = static::normalizeListeners($listeners); - foreach ($sources as $source) { - foreach ($source as $event => $method) { - $event = is_numeric($event) ? $method : $event; + foreach (static::normalizeListeners($overlay) as $event => $method) { + if ($method === false || $method === null) { + unset($listeners[$event]); + continue; + } - if ($method === false || $method === null) { - unset($listeners[$event]); - continue; - } + $listeners[$event] = $method; + } - $listeners[$event] = $method; + return $listeners; + } + + protected static function getListenerTombstones(array $listeners): array + { + $tombstones = []; + + foreach (static::normalizeListeners($listeners) as $event => $method) { + if ($method !== false && $method !== null) { + continue; } + + $tombstones[$event] = $method; } - return $listeners; + return $tombstones; + } + + protected static function normalizeListeners(array $listeners): array + { + $normalized = []; + + foreach ($listeners as $event => $method) { + $event = is_numeric($event) ? $method : $event; + $normalized[$event] = $method; + } + + return $normalized; + } + + private function ensureListenerIsNotRemoved(string $listener): void + { + if (in_array($listener, $this->getRemovedListenerNames(), true)) { + throw new EventHandlerDoesNotExist($listener); + } + } + + private function getRemovedListenerNames(): array + { + $component = $this->component(); + $tombstones = static::getListenerTombstones(static::getLayoutListeners($component)); + $tombstones = SupportEvents::replaceDynamicEventNamePlaceholders($tombstones, $component); + + return array_keys($tombstones); } } diff --git a/src/etc/adminhtml/di.xml b/src/etc/adminhtml/di.xml index 40e465c7..5779a7d4 100644 --- a/src/etc/adminhtml/di.xml +++ b/src/etc/adminhtml/di.xml @@ -204,10 +204,15 @@ - Magewirephp\Magewire\Features\SupportMagewireEvents\SupportMagewireEvents + Magewirephp\Magewire\Features\SupportEvents\SupportEvents 1600 + + Magewirephp\Magewire\Features\SupportMagewireEvents\SupportMagewireEvents + 1650 + + Magewirephp\Magewire\Features\SupportMagentoLayouts\SupportMagentoLayouts 5000 diff --git a/src/etc/frontend/di.xml b/src/etc/frontend/di.xml index cc710c7d..b9e12283 100644 --- a/src/etc/frontend/di.xml +++ b/src/etc/frontend/di.xml @@ -192,10 +192,15 @@ - Magewirephp\Magewire\Features\SupportMagewireEvents\SupportMagewireEvents + Magewirephp\Magewire\Features\SupportEvents\SupportEvents 1600 + + Magewirephp\Magewire\Features\SupportMagewireEvents\SupportMagewireEvents + 1650 + + Magewirephp\Magewire\Features\SupportMultipleRootElementDetection\SupportMultipleRootElementDetection 1700 diff --git a/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php index 92ea362e..02acb6b6 100644 --- a/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php +++ b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php @@ -10,54 +10,56 @@ class SupportMagewireEventsTest extends TestCase { - public function test_it_layers_normalized_listener_sources_in_order(): void + public function test_it_layers_layout_handlers_over_attribute_listeners(): void { - $listeners = $this->mergeListenerSources( - [ - 'shorthandListener', - 'class:kept' => 'onClassKept', - 'shared:event' => 'onClassShared' - ], - [ - 'attribute:kept' => 'onAttributeKept', - 'shared:event' => 'onAttributeShared' - ], - [ - 'layout:added' => 'onLayoutAdded', - 'shared:event' => 'onLayoutShared' - ] - ); + $listeners = $this->applyListenerOverlay([ + 'shorthandListener', + 'attribute:removed' => 'onAttributeRemoved', + 'shared:event' => 'onAttributeShared' + ], [ + 'attribute:removed' => false, + 'layout:added' => 'onLayoutAdded', + 'shared:event' => 'onLayoutShared' + ]); self::assertSame( [ 'shorthandListener' => 'shorthandListener', - 'class:kept' => 'onClassKept', 'shared:event' => 'onLayoutShared', - 'attribute:kept' => 'onAttributeKept', 'layout:added' => 'onLayoutAdded' ], $listeners ); } - public function test_false_and_null_remove_listeners_from_earlier_sources(): void + public function test_false_and_null_are_preserved_as_listener_tombstones(): void { - $listeners = $this->mergeListenerSources([ - 'class:removed' => 'onClassRemoved', - 'class:null-removed' => 'onClassNullRemoved', - 'class:kept' => 'onClassKept' - ], [ + $tombstones = $this->getListenerTombstones([ 'class:removed' => false, - 'class:null-removed' => null + 'class:null-removed' => null, + 'layout:kept' => 'onLayoutKept' ]); - self::assertSame(['class:kept' => 'onClassKept'], $listeners); + self::assertSame( + [ + 'class:removed' => false, + 'class:null-removed' => null + ], + $tombstones + ); + } + + private function applyListenerOverlay(array $listeners, array $overlay): array + { + $method = new ReflectionMethod(SupportMagewireEvents::class, 'applyListenerOverlay'); + + return $method->invoke(null, $listeners, $overlay); } - private function mergeListenerSources(array ...$sources): array + private function getListenerTombstones(array $listeners): array { - $method = new ReflectionMethod(SupportMagewireEvents::class, 'mergeListenerSources'); + $method = new ReflectionMethod(SupportMagewireEvents::class, 'getListenerTombstones'); - return $method->invoke(null, ...$sources); + return $method->invoke(null, $listeners); } } From b265862fdc198656226fd7d8ce5d31faa8576ab1 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Thu, 17 Sep 2026 10:07:05 +0200 Subject: [PATCH 05/13] refactor(events): simplify listener effect update --- .../SupportMagewireEvents/SupportMagewireEvents.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php index 836427b6..d3e51817 100644 --- a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php +++ b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php @@ -60,12 +60,7 @@ public function dehydrate(ComponentContext $context): void $removed = $this->getRemovedListenerNames(); $listeners = array_values(array_filter($context->getEffects()->getData('listeners', []), static fn ($listener) => ! in_array($listener, $removed, true))); - if ($listeners === []) { - $context->getEffects()->exclude('listeners'); - return; - } - - $context->addEffect('listeners', $listeners); + $listeners === [] ? $context->getEffects()->exclude('listeners') : $context->addEffect('listeners', $listeners); } /** From f2397bcaaea3080f4a135d6322190d0a5b709db5 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Thu, 17 Sep 2026 10:29:45 +0200 Subject: [PATCH 06/13] chore: exclude agent plans from PR --- .../PLAN.md | 301 ------------------ .../investigation.md | 279 ---------------- 2 files changed, 580 deletions(-) delete mode 100644 .agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md delete mode 100644 .agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md diff --git a/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md b/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md deleted file mode 100644 index de5ff59e..00000000 --- a/.agents/plans/feat-2026-09-15-layout-feature-overrides/PLAN.md +++ /dev/null @@ -1,301 +0,0 @@ -# TL;DR - -Introduce selective, per-block feature configuration through `magewire:` layout -arguments, including keyed removal through `false` or `null` tombstones. - -The implemented first slice is event listeners. A standalone Magewire feature -now composes with the ported event feature through ordered lifecycle hooks; it -does not extend, replace, or add Magento knowledge to `SupportEvents`. Loaders -are the next credible candidate. Arbitrary component-property mutation is not -recommended. - -For this slice, "from that point on" means later layout XML declarations before -initial render. Live reconciliation after a component has mounted in the browser -is explicitly deferred. - -# Context - -- Started: 2026-09-15 -- Initial type: Feature -- Current type: Feature -- Status: Listener slice implemented and verified -- Branch: `feat/layout-listener-overrides` -- Draft PR: #307 - -# Goal - -Allow approved Magewire features to layer per-placement layout configuration over -component and attribute defaults, so later layout XML can add, replace, or remove -keyed entries without leaking removal markers into feature behavior. - -# Tasks - -- [x] Trace the `magewire:` layout-argument grammar and lifecycle -- [x] Verify Magento's array-merge and `null` interpretation behavior -- [x] Inventory active property- and attribute-backed feature configuration -- [x] Rank viable configuration candidates -- [x] Confirm the first feature/property slice -- [x] Define the listener overlay contract and precedence -- [x] Define the static layout-time versus live browser boundary -- [x] Define invalid-value and whole-configuration behavior -- [x] Plan focused unit/integration/browser coverage -- [x] Implement the accepted slice -- [x] Test -- [x] Review - -# Decisions - -## ✅ Use feature-owned adapters, not generic property mutation - -Each participating feature should explicitly consume and validate its own layout -configuration. The mechanism should not reflect into arbitrary protected -properties or assign arbitrary public component state. - -**Reasoning** - -- Listeners and loaders have different normalization and removal rules. -- Public properties are reactive state, not feature configuration. -- Some attribute-backed behavior, especially locked properties, is a security - boundary and must not be weakened by a generic override mechanism. -- Starting with explicit feature consumers keeps the compatibility surface small - and lets a shared abstraction emerge only after multiple proven consumers. - -**Evidence** - -- `src/Component.php:30-49` -- `dist/Features/SupportEvents/SupportEvents.php:49-72` -- `lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php:21-42` -- `dist/Features/SupportLockedProperties/BaseLocked.php:13-18` - -**Owner** - -Magewire - -## ✅ Compose with the ported event feature instead of replacing it - -The ported `SupportEvents` feature remains registered unchanged. The external -`SupportMagewireEvents` feature is registered separately at a later sort order. -It contributes active layout handlers during `boot()`, rejects tombstoned -dispatches through a pre-call guard, and filters the browser listener effect in -its later `dehydrate()` hook. - -If the core event feature is disabled, the Magewire feature skips itself. The -Portman source and generated `SupportEvents` class remain unaware of Magento -layout arguments and Magewire resolver methods. - -**Reasoning** - -- Portman-owned files must remain portable representations of upstream - Livewire behavior. -- Magento layout arguments and `magewireResolver()` belong to Magewire's - integration layer. -- Dehydration alone can change browser subscriptions, but server dispatch - authorization and method resolution happen earlier in `SupportEvents::call()`. -- Ordered composition preserves both server and browser behavior without - replacing the core feature. -- Keeping active layout handlers in the existing attribute-backed listener - source preserves dynamic component `getListeners()` implementations. - -**Evidence** - -- `lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php` -- `src/etc/frontend/di.xml` -- `src/etc/adminhtml/di.xml` -- `dist/ComponentHookRegistry.php:42-75` -- `dist/Features/SupportEvents/SupportEvents.php:21-72` - -**Owner** - -Willem - -## ✅ Implement listeners first - -Recommended first slice: layer a layout listener map over class and `#[On]` -listeners, then remove entries whose final layout value is `false` or `null`. - -**Reasoning** - -- It is the motivating use case. -- The feature already centralizes listener lookup for both request authorization - and the initial browser effect. -- Tombstones solve a concrete Magento layout-composition problem. -- The work will establish reusable precedence and normalization semantics before - attempting the more polymorphic loader configuration. - -**Evidence** - -- `dist/Features/SupportEvents/HandlesEvents.php:13-19` -- `dist/Features/SupportEvents/BaseOn.php:16-26` -- `dist/Features/SupportEvents/SupportEvents.php:21-72` - -**Owner** - -Willem - -## ✅ Limit the first slice to final server-side layout composition - -Recommended scope: a later layout XML declaration can override or tombstone an -earlier layout/class/attribute listener before initial browser registration. - -Changing the effective listener set on an already-mounted component should be a -separate follow-up unless it is explicitly required. Current browser handling -only adds listeners from effects and removes them during component cleanup; it -does not reconcile a changing listener set. - -**Evidence** - -- `dist/Features/SupportEvents/SupportEvents.php:40-47` -- `src/view/base/web/js/magewire.esm.js:10631-10654` - -**Owner** - -Willem - -## ✅ Use `magewire:listeners` as the public argument shape - -Recommended starting shape: `magewire:listeners`, an associative array keyed by -event name. String values add or replace a method; `false` and `null` values -remove that event after all sources have been normalized. - -The alternative is a feature group such as `magewire:events:listeners`. Existing -code supports both top-level and grouped argument grammar, so naming should be -settled before implementation. - -Accepted contract: - -- The value must be an associative listener array; a non-array top-level value - is ignored for this first slice. -- String values add or replace handlers. -- Keyed `false` and `null` values remove that event from earlier class, - attribute, or layout sources. -- Whole-map clearing is not included. - -**Evidence** - -- `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:79-133` - -**Owner** - -Willem - -# Investigation - -## VERIFIED - -- Magewire assembles top-level `magewire:*`, legacy `magewire.*`, and grouped - `magewire::` values from block data. Arguments are reassembled from - the reconstructed Magento block on subsequent requests. -- Magento merges repeated block argument arrays with `array_replace_recursive`, - and its `null` argument interpreter preserves an explicit `null`. A later - named listener item can therefore survive layout merging as a tombstone. -- `magewire:component:lazy` already provides a working feature-level precedent: - false-like values disable attribute-driven lazy loading and a layout mode can - override `#[Lazy]`. -- The active listener feature combines class listeners with `#[On]` listeners in - one resolution method. That method is used for both event-call authorization - and the listener names sent to the browser. -- The browser listener implementation is additive. Listener cleanup is tied to - component destruction, not to a later effect containing a smaller set. -- Loader configuration is also property-backed and centralized, but accepts - several shapes (`bool`, `string`, list, or map), making its overlay contract - more complex than listeners. -- Validation `$rules` and `$messages` are configurable maps only on the legacy - `Component\Form` path in the current base component. The ported validation - traits are not enabled on `Component`, so they are a poor first target. -- Pagination data is public component state, while redirects, streams, - notifications, flash messages, and errors are runtime commands/queues. They - are not static configuration-overlay candidates. -- `DataCollection::has()` and `get()` use `isset`/null-coalescing semantics. A - whole argument explicitly set to `null` cannot currently be distinguished from - a missing argument through those accessors. Nested `null` values inside an - argument array do survive and can be inspected. - -## DECIDED - -- "From that point on" primarily refers to Magento layout merge order before a - component is delivered to the browser. -- Layout configuration should have the highest precedence because its purpose is - to customize one component placement without changing the component class. -- Tombstones should be processed before dynamic listener placeholders are - expanded, so a layout can remove the same declarative event key used by a - component or `#[On]` attribute. - -## DEFERRED - -- Whole-map clearing through `magewire:listeners="false|null"`; this slice only - supports keyed tombstones inside the listener array. -- Live, post-mount listener reconciliation; this slice resolves the final - Magento layout composition before initial browser registration. -- Earlier validation of invalid listener methods; values other than strict - `false`/`null` retain the existing failure-on-dispatch behavior. - -# Candidate summary - -| Priority | Feature/property | Fit | Direction | -|---|---|---|---| -| 1 | Events / `$listeners` | Strong keyed map and central resolver | Implemented | -| 2 | Magewire loaders / `$loader` | Useful keyed removals, but polymorphic input | Follow after listener semantics settle | -| 3 | Lazy loading / `#[Lazy]` options | Layout override already exists; `isolate` is the main gap | Small independent extension | -| 4 | Legacy form / `$rules`, `$messages` | Technically mergeable, but legacy and validation-sensitive | Defer | -| — | Pagination / `$paginators` | Reactive state, not configuration | Exclude | -| — | Locked properties | Security boundary | Exclude | -| — | Redirects, streams, notifications, flash messages, errors | Runtime effects or queues | Exclude | - -# Tooling - -- Repository source and tests -- Magento framework layout reader and argument interpreters in the host application -- Git history for Magewire V1 and V3 behavior -- `caveman-explore` for cross-file localization - -# Supporting documents - -- [Candidate investigation](./investigation.md) - -# Implementation - -- Registered the original ported `SupportEvents` feature unchanged and added an - independent `SupportMagewireEvents` feature immediately after it in frontend - and adminhtml areas. -- Kept the Portman source and generated `SupportEvents` class free of - Magewire-specific resolver access. -- Active layout handlers are layered into the existing attribute listener - source during `boot()`, before server calls and dehydration. -- Tombstoned server dispatches are rejected by a pre-call guard, while the - later dehydration hook removes tombstoned names from the browser effect. -- The Magewire adapter skips itself when the core event feature is disabled. -- Included the upstream `EventHandlerDoesNotExist` exception that the active - server authorization path already references, so rejected tombstoned events - fail with the intended error instead of a missing-class error. -- Listener sources are normalized and layered in this order: component class, - `#[On]` attributes, then `magewire:listeners`. -- Tombstones are resolved before dynamic event placeholders are expanded. -- Added focused unit coverage for normalization, precedence, and tombstones. -- Added a Magento layout and Playwright fixture covering class listeners, - attributes, later layout replacement/removal, browser effects, and real event - dispatch. - -# Verification - -- `mago lint` on all changed PHP source and test files -- `mago format --check` on all changed PHP source and test files -- Portman build with no generated `SupportEvents` diff -- `php-cs-fixer --dry-run` on the generated event-handler exception -- Magento dependency-injection compilation -- PHPUnit: 28 tests passed (full unit suite) -- Playwright events spec: 5 tests passed - -# Change log - -- 2026-09-15: Created the feature work item, completed candidate discovery, and - recommended listeners as the first implementation slice. -- 2026-09-16: Accepted and implemented the `magewire:listeners` slice with - keyed `false`/`null` tombstones, generated output, and focused unit/browser - coverage. -- 2026-09-16: Moved layout-aware event resolution out of the Portman layer into - an externally registered Magewire feature subclass. -- 2026-09-16: Created `feat/layout-listener-overrides` from `main` for draft - review. -- 2026-09-16: Opened draft pull request #307. -- 2026-09-17: Reworked the listener integration into an independently ordered - Magewire feature, restoring the original ported event feature registration. diff --git a/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md b/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md deleted file mode 100644 index e5b1ce9c..00000000 --- a/.agents/plans/feat-2026-09-15-layout-feature-overrides/investigation.md +++ /dev/null @@ -1,279 +0,0 @@ -# Layout feature override investigation - -# Question - -Which Magewire component properties or feature settings are suitable for -per-placement `magewire:` layout overrides with additive, replacement, and -`false`/`null` removal semantics, and which should be implemented first? - -# Current argument model - -`MagewireArguments` currently recognizes three forms: - -| Form | Current role | Evidence | -|---|---|---| -| `magewire:` | Top-level component/resolver metadata such as `id`, `name`, and `alias` | `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:79-95`, `lib/Magewire/Mechanisms/ResolveComponents/ComponentResolver/LayoutResolver.php:173-192` | -| `magewire::` | Named configuration groups; currently `mount` and `component` are consumed | `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:48-61`, `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:117-135` | -| `magewire.` | Collected into a `public` subset, but no current consumer was found | `lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/MagewireArguments.php:98-115` | - -The argument collection is reassembled after Magento constructs or reconstructs -the block and before component build hooks run. `magewire:mount:*` is passed into -the component mount lifecycle. Feature code can read other groups directly. - -Evidence: - -- `lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php:84-108` -- `src/Observer/ViewBlockAbstractToHtmlBefore.php:102-108` -- `portman/Livewire/Features/SupportLazyLoading/SupportLazyLoading.php:22-69` - -# Why tombstones work with Magento layout merging - -Magento reads later block/referenceBlock argument declarations into the existing -argument array with `array_replace_recursive`. Named nested items are therefore -replaced by later declarations instead of merely appended. The `xsi:type="null"` -interpreter evaluates to PHP `null`. - -That makes this layout composition viable: - -```xml - - - refreshTotals - - - - - - -``` - -The final block data can contain `['checkout:updated' => null]`; Magewire must -interpret it as removal instead of forwarding it to the feature. - -Evidence from the host Magento application: - -- `vendor/magento/framework/View/Layout/Reader/Block.php:250-260` -- `vendor/magento/framework/View/Layout/Reader/Block.php:356-370` -- `vendor/magento/framework/Data/Argument/Interpreter/ArrayType.php:44-55` -- `vendor/magento/framework/Data/Argument/Interpreter/NullType.php:13-23` - -One framework caveat matters for whole-value `null`: Magewire's -`DataCollection::has()` delegates to `isset()`, and `get()` uses null coalescing. -An explicit top-level `null` is indistinguishable from absence through these -methods. A nested `null` inside a top-level array remains inspectable via the -array value. - -Evidence: - -- `lib/Magewire/Support/DataCollection.php:188-215` -- `lib/Magewire/Support/DataCollection.php:300-305` - -# Candidate assessment - -## 1. Event listeners — strongest first slice - -Sources currently include: - -1. component `$listeners` via `HandlesEvents::getListeners()`; -2. `#[On]` attributes stored as `listenersFromAttributes`; -3. no layout source yet. - -`SupportEvents::getComponentListeners()` is the central merge point. Its result -controls both whether an incoming `__dispatch` event is authorized and which -event names are sent to the browser on mount. - -Evidence: - -- `dist/Features/SupportEvents/HandlesEvents.php:13-25` -- `dist/Features/SupportEvents/BaseOn.php:16-27` -- `dist/Features/SupportEvents/SupportEvents.php:21-72` - -Recommended effective-source order: - -```text -class $listeners - -> #[On] listeners - -> layout listener overlay - -> remove false/null entries - -> expand dynamic placeholders -``` - -All sources should be normalized to `event => method` before the overlay is -applied. This lets a named tombstone remove either an associative listener or a -numeric shorthand such as `['checkout:updated']`. - -Recommended initial shape: - -```xml - - refreshTotals - false - - -``` - -Why first: - -- It is the requested use case. -- It has a single server-side resolution point. -- A false/null entry is currently unsafe: the event key can remain visible while - its method is not callable. -- It establishes normalization, precedence, tombstone, and validation semantics - that can later inform other features. - -### Browser boundary - -For initial layout composition, filtering before dehydration is sufficient: the -browser never receives removed listener names. - -Live post-mount changes are different. `SupportEvents` sends listener names only -while mounting, and the browser registers every received name additively. It -removes handlers only during component cleanup. Supporting a shrinking listener -set after mount would require a reconciliation effect and per-listener cleanup, -not only a PHP merge. - -Evidence: - -- `dist/Features/SupportEvents/SupportEvents.php:40-47` -- `src/view/base/web/js/magewire.esm.js:10631-10654` - -## 2. Magewire loaders — good second slice - -The loader feature reads protected `$loader` through `getLoader()` and emits a -normalized client effect. The client matches configuration against component -method calls and property updates. - -Evidence: - -- `lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php:14-25` -- `lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php:19-43` -- `src/view/base/templates/magewire-features/support-magewire-loaders/support-magewire-loaders.phtml:146-246` - -Per-action layout additions and removals are useful, for example disabling an -inherited loader for one action on one placement. This should follow listeners -because loader configuration is polymorphic: - -- `false` disables all loaders; -- `true` enables general loading behavior; -- a string is a general message; -- a list selects calls/updates; -- an associative map selects and optionally supplies messages. - -A correct overlay must define how a scalar layout value interacts with a class -map, how numeric entries are normalized, and whether false/null entries mean -"remove inherited selector" or "keep a disabled selector." These are more -branches than the listener contract needs. - -## 3. Lazy loading — precedent, not the best new slice - -`magewire:component:lazy` already: - -- opts a component into lazy loading; -- disables `#[Lazy]` with false-like values; -- overrides an attribute's trigger mode per layout placement. - -The main unexposed option is `#[Lazy(isolate: ...)]`. Adding a layout override -for isolation is feasible, but it is a scalar override rather than the requested -keyed add/remove model. - -Evidence: - -- `portman/Livewire/Features/SupportLazyLoading/SupportLazyLoading.php:22-69` -- `portman/Livewire/Features/SupportLazyLoading/BaseLazy.php:7-15` -- `tests/Playwright/tests/lazy-loading.spec.js:121-126` -- `tests/Playwright/tests/lazy-loading.spec.js:377-410` - -## 4. Validation maps — defer - -The legacy `Component\Form` has protected `$rules` and `$messages` maps. They are -technically compatible with named layout overlays and tombstones. - -They should not lead this work because: - -- this is a legacy form path; -- the ported Livewire validation traits are present in generated source but are - not enabled on the current base `Component`; -- removing or replacing validation rules changes a correctness/security policy, - so its contract needs stricter validation and documentation than presentation - configuration. - -Evidence: - -- `src/Component/Form.php:21-68` -- `src/Component.php:30-49` -- `dist/Features/SupportValidation/HandlesValidation.php:25-127` - -## Excluded candidates - -### Pagination - -`$paginators` is public reactive state. Layout defaults can already reach mount -parameters, but generic runtime-state overlays should not be mixed with feature -configuration. - -- `dist/Features/SupportPagination/HandlesPagination.php:14-65` - -### Locked properties - -`#[Locked]` prevents client updates. A generic layout-property override must not -be able to remove or weaken this security boundary. - -- `dist/Features/SupportLockedProperties/BaseLocked.php:13-18` - -### Redirects, streams, notifications, flash messages, and errors - -These are actions, transient stores, effects, or queues produced during a -request. They do not represent declarative component configuration to merge at -block construction time. - -- `dist/Features/SupportRedirects/HandlesRedirects.php:14-24` -- `lib/Magewire/Features/SupportMagewireNotifications/HandlesMagewireNotifications.php:16-23` -- `lib/Magewire/Features/SupportMagentoFlashMessages/HandlesMagewireFlashMessages.php:17-24` -- `lib/MagewireBc/Model/Concern/Error.php:19-29` - -# Recommended first acceptance contract - -If listeners are selected, the first slice should aim for these conditions: - -1. A layout map can add a listener by event name. -2. A later layout declaration can replace the handler for an event name. -3. `false` or `null` removes the named listener regardless of whether it came - from `$listeners`, `#[On]`, or an earlier layout declaration. -4. Removed names are absent from the initial browser `listeners` effect. -5. Removed names fail server-side event authorization exactly like any unknown - listener. -6. Numeric shorthand and associative listener definitions normalize to the same - event-keyed model before layout overlay. -7. Dynamic placeholders are expanded only after layout removal is resolved. -8. Existing components without the layout argument behave identically. -9. Live post-mount reconciliation remains out of scope for this first slice. - -# Testing seams - -The implementation plan should cover: - -- pure listener normalization/overlay cases, including false and null; -- class listener addition, replacement, and removal; -- `#[On]` removal and precedence; -- dynamic placeholder ordering; -- server rejection of a tombstoned listener; -- absence of tombstoned names from initial `wire:effects`; -- Magento layout composition with two declarations for the same named item; -- regression coverage for components without `magewire:listeners`. - -Although `SupportEvents` is Portman-generated under `dist/`, Magewire-specific -integration must remain external to the Portman source. The implementation -keeps the original event feature registered and adds `SupportMagewireEvents` as -an independent, later-running feature under `lib/Magewire`. - -A dehydration-only overlay would be incomplete: it can replace the browser -effect, but event authorization and method resolution already occur in -`SupportEvents::call()`. The companion feature therefore adds active layout -handlers during `boot()`, rejects tombstoned calls through a pre-call guard, and -uses its later `dehydrate()` hook only for the final browser effect. - -Evidence: - -- `lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php` -- `src/etc/frontend/di.xml` -- `src/etc/adminhtml/di.xml` From 386f6f3550b032842f1e1e69b80c67ca133f2b33 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 23 Sep 2026 12:45:52 +0200 Subject: [PATCH 07/13] refactor(layout): share argument overlays with loaders --- .../SupportMagewireEvents.php | 53 ++---------- .../SupportMagewireLoaders.php | 3 +- .../LayoutArgumentOverlay.php | 84 +++++++++++++++++++ src/Magewire/Playwright/Events/Basic.php | 5 ++ .../layout/magewire_playwright_events.xml | 5 ++ tests/Playwright/tests/events.spec.js | 11 +++ .../SupportMagewireEventsTest.php | 38 ++++----- .../SupportMagewireLoadersTest.php | 52 ++++++++++++ .../Mechanisms/LayoutArgumentOverlayTest.php | 76 +++++++++++++++++ 9 files changed, 261 insertions(+), 66 deletions(-) create mode 100644 lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php create mode 100644 tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php create mode 100644 tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php diff --git a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php index d3e51817..929c8ee2 100644 --- a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php +++ b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php @@ -16,11 +16,11 @@ use Magewirephp\Magewire\Exceptions\EventHandlerDoesNotExist; use Magewirephp\Magewire\Features\SupportEvents\SupportEvents; use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\LayoutArgumentOverlay; use function Magewirephp\Magewire\before; use function Magewirephp\Magewire\store; -/** @mago-expect lint:cyclomatic-complexity */ class SupportMagewireEvents extends ComponentHook { public static function provide(): void @@ -48,7 +48,11 @@ public function boot(): void $component = $this->component(); $fromAttributes = store($component)->get('listenersFromAttributes', []); - store($component)->set('listenersFromAttributes', static::applyListenerOverlay($fromAttributes, static::getLayoutListeners($component))); + store($component)->set('listenersFromAttributes', LayoutArgumentOverlay::apply( + static::normalizeListeners($fromAttributes), + static::normalizeListeners(static::getLayoutListeners($component)), + [false, null] + )); } public function dehydrate(ComponentContext $context): void @@ -69,52 +73,11 @@ public function dehydrate(ComponentContext $context): void */ protected static function getLayoutListeners($component): array { - $resolver = $component->magewireResolver(); - - if ($resolver === null) { - return []; - } - - $listeners = $resolver->arguments()->get('listeners', []); + $listeners = LayoutArgumentOverlay::get($component, 'listeners', []); return is_array($listeners) ? $listeners : []; } - /** - * Add layout handlers to the source consumed by SupportEvents. Tombstones - * are enforced by the pre-call guard and the later dehydration hook. - */ - protected static function applyListenerOverlay(array $listeners, array $overlay): array - { - $listeners = static::normalizeListeners($listeners); - - foreach (static::normalizeListeners($overlay) as $event => $method) { - if ($method === false || $method === null) { - unset($listeners[$event]); - continue; - } - - $listeners[$event] = $method; - } - - return $listeners; - } - - protected static function getListenerTombstones(array $listeners): array - { - $tombstones = []; - - foreach (static::normalizeListeners($listeners) as $event => $method) { - if ($method !== false && $method !== null) { - continue; - } - - $tombstones[$event] = $method; - } - - return $tombstones; - } - protected static function normalizeListeners(array $listeners): array { $normalized = []; @@ -137,7 +100,7 @@ private function ensureListenerIsNotRemoved(string $listener): void private function getRemovedListenerNames(): array { $component = $this->component(); - $tombstones = static::getListenerTombstones(static::getLayoutListeners($component)); + $tombstones = LayoutArgumentOverlay::removed(static::normalizeListeners(static::getLayoutListeners($component)), [false, null]); $tombstones = SupportEvents::replaceDynamicEventNamePlaceholders($tombstones, $component); return array_keys($tombstones); diff --git a/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php index fa4074f1..024d2a79 100644 --- a/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php +++ b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php @@ -13,6 +13,7 @@ use Magewirephp\Magewire\ComponentHook; use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\LayoutArgumentOverlay; use function Magewirephp\Magewire\map_with_keys; @@ -20,7 +21,7 @@ class SupportMagewireLoaders extends ComponentHook { function dehydrate(ComponentContext $context): void { - $loader = $context->component->getLoader(); + $loader = LayoutArgumentOverlay::value($context->component, 'loader', $context->component->getLoader(), [null]); if ($loader) { if (is_array($loader)) { diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php new file mode 100644 index 00000000..4fe49066 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php @@ -0,0 +1,84 @@ +magewireResolver(); + + if ($resolver === null) { + return $default; + } + + $arguments = $resolver->arguments()->all(); + + return array_key_exists($name, $arguments) ? $arguments[$name] : $default; + } + + /** + * Use the layout value when present. Arrays add entries and replace keys; + * selected keyed values can remove entries from the original array. + */ + public static function value(Component $component, string $name, mixed $original, array $removals = []): mixed + { + $resolver = $component->magewireResolver(); + + if ($resolver === null) { + return $original; + } + + $arguments = $resolver->arguments()->all(); + + if (! array_key_exists($name, $arguments)) { + return $original; + } + + return self::apply($original, $arguments[$name], $removals); + } + + public static function apply(mixed $original, mixed $overlay, array $removals = []): mixed + { + if (! is_array($overlay)) { + return $overlay; + } + + $original = is_array($original) ? $original : []; + + foreach ($overlay as $key => $value) { + if (in_array($value, $removals, true)) { + if (! is_int($key)) { + unset($original[$key]); + } + + continue; + } + + if (is_int($key)) { + $original[] = $value; + continue; + } + + $original[$key] = $value; + } + + return $original; + } + + public static function removed(array $overlay, array $removals): array + { + return array_filter($overlay, static fn ($value) => in_array($value, $removals, true)); + } +} diff --git a/src/Magewire/Playwright/Events/Basic.php b/src/Magewire/Playwright/Events/Basic.php index 7ceaadb1..d1e3d4e4 100644 --- a/src/Magewire/Playwright/Events/Basic.php +++ b/src/Magewire/Playwright/Events/Basic.php @@ -29,6 +29,11 @@ class Basic extends Component 'dynamic:{scope}' => 'onDynamicListener' ]; + protected $loader = [ + 'onClassKept' => 'Class loading', + 'onClassRemoved' => 'Removed loading' + ]; + public function onClassKept(): void { $this->result = 'class-kept'; diff --git a/src/view/frontend/layout/magewire_playwright_events.xml b/src/view/frontend/layout/magewire_playwright_events.xml index eeb13985..45071c8e 100644 --- a/src/view/frontend/layout/magewire_playwright_events.xml +++ b/src/view/frontend/layout/magewire_playwright_events.xml @@ -23,6 +23,11 @@ onAttributeReplacement + + Layout loading + + Added loading + diff --git a/tests/Playwright/tests/events.spec.js b/tests/Playwright/tests/events.spec.js index 5554ec6f..f00ea7f8 100644 --- a/tests/Playwright/tests/events.spec.js +++ b/tests/Playwright/tests/events.spec.js @@ -60,6 +60,17 @@ test.describe('Magewire Playwright — Events', () => { ])); }); + test('merges loader entries from the layout with the component loader', async ({ request }) => { + const html = await (await request.get(PATH)).text(); + const tag = rootTag(html, ID); + + expect(tag).toBeTruthy(); + expect(effectsFromTag(tag).loader[0]).toEqual({ + onClassKept: ['Layout loading'], + onLayoutAdded: ['Added loading'], + }); + }); + test('dispatches kept and layout-added listeners', async ({ page }) => { const result = component(page).getByTestId('event-result'); diff --git a/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php index 02acb6b6..90f854af 100644 --- a/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php +++ b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php @@ -5,6 +5,7 @@ namespace Magewirephp\Magewire\Tests\Unit\Features\SupportMagewireEvents; use Magewirephp\Magewire\Features\SupportMagewireEvents\SupportMagewireEvents; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\LayoutArgumentOverlay; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -12,15 +13,19 @@ class SupportMagewireEventsTest extends TestCase { public function test_it_layers_layout_handlers_over_attribute_listeners(): void { - $listeners = $this->applyListenerOverlay([ - 'shorthandListener', - 'attribute:removed' => 'onAttributeRemoved', - 'shared:event' => 'onAttributeShared' - ], [ - 'attribute:removed' => false, - 'layout:added' => 'onLayoutAdded', - 'shared:event' => 'onLayoutShared' - ]); + $listeners = LayoutArgumentOverlay::apply( + $this->normalizeListeners([ + 'shorthandListener', + 'attribute:removed' => 'onAttributeRemoved', + 'shared:event' => 'onAttributeShared' + ]), + $this->normalizeListeners([ + 'attribute:removed' => false, + 'layout:added' => 'onLayoutAdded', + 'shared:event' => 'onLayoutShared' + ]), + [false, null] + ); self::assertSame( [ @@ -34,11 +39,11 @@ public function test_it_layers_layout_handlers_over_attribute_listeners(): void public function test_false_and_null_are_preserved_as_listener_tombstones(): void { - $tombstones = $this->getListenerTombstones([ + $tombstones = LayoutArgumentOverlay::removed($this->normalizeListeners([ 'class:removed' => false, 'class:null-removed' => null, 'layout:kept' => 'onLayoutKept' - ]); + ]), [false, null]); self::assertSame( [ @@ -49,16 +54,9 @@ public function test_false_and_null_are_preserved_as_listener_tombstones(): void ); } - private function applyListenerOverlay(array $listeners, array $overlay): array - { - $method = new ReflectionMethod(SupportMagewireEvents::class, 'applyListenerOverlay'); - - return $method->invoke(null, $listeners, $overlay); - } - - private function getListenerTombstones(array $listeners): array + private function normalizeListeners(array $listeners): array { - $method = new ReflectionMethod(SupportMagewireEvents::class, 'getListenerTombstones'); + $method = new ReflectionMethod(SupportMagewireEvents::class, 'normalizeListeners'); return $method->invoke(null, $listeners); } diff --git a/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php b/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php new file mode 100644 index 00000000..ae0560a8 --- /dev/null +++ b/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php @@ -0,0 +1,52 @@ + 'Class saving', + 'delete' => 'Class deleting' + ]; + }; + + $arguments = $this->createMock(MagewireArguments::class); + $arguments + ->method('all') + ->willReturn(['loader' => [ + 'save' => 'Layout saving', + 'delete' => null, + 'publish' => 'Layout publishing' + ]]); + + $resolver = $this->createMock(ComponentResolver::class); + $resolver->method('arguments')->willReturn($arguments); + $component->magewireResolver($resolver); + + $context = new ComponentContext(null, $component, true, new Effects(), new Memo()); + $hook = new SupportMagewireLoaders(); + $hook->setComponent($component); + $hook->dehydrate($context); + + $loader = $context->getEffects()->getData('loader')[0]; + + self::assertSame(['save', 'publish'], array_keys($loader)); + self::assertSame('Layout saving', (string) $loader['save'][0]); + self::assertSame('Layout publishing', (string) $loader['publish'][0]); + self::assertSame(['save' => 'Class saving', 'delete' => 'Class deleting'], $component->getLoader()); + } +} diff --git a/tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php b/tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php new file mode 100644 index 00000000..ae837dff --- /dev/null +++ b/tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php @@ -0,0 +1,76 @@ + 'Layout saving', + 'layoutAction' + ], + LayoutArgumentOverlay::apply( + [ + 'classAction', + 'save' => 'Class saving', + 'delete' => 'Class deleting' + ], + [ + 'layoutAction', + 'save' => 'Layout saving', + 'delete' => null + ], + [null] + ) + ); + } + + public function test_it_preserves_false_when_the_consumer_only_removes_null(): void + { + self::assertSame(['save' => false], LayoutArgumentOverlay::apply(['save' => 'Class saving'], ['save' => false], [null])); + } + + public function test_it_discards_removals_when_an_array_replaces_a_scalar(): void + { + self::assertSame(['save' => 'Layout saving'], LayoutArgumentOverlay::apply(false, ['save' => 'Layout saving', 'obsolete' => null], [null])); + } + + public function test_it_uses_a_layout_scalar_even_when_it_is_null(): void + { + $component = $this->componentWithArguments(['loader' => null]); + + self::assertNull(LayoutArgumentOverlay::value($component, 'loader', ['save' => 'Class saving'])); + } + + public function test_it_leaves_the_original_value_when_the_argument_is_absent(): void + { + $component = $this->componentWithArguments([]); + + self::assertSame(['save'], LayoutArgumentOverlay::value($component, 'loader', ['save'])); + } + + private function componentWithArguments(array $values): Component + { + $arguments = $this->createMock(MagewireArguments::class); + $arguments->method('all')->willReturn($values); + + $resolver = $this->createMock(ComponentResolver::class); + $resolver->method('arguments')->willReturn($arguments); + + $component = new class extends Component {}; + $component->magewireResolver($resolver); + + return $component; + } +} From 3658826f99d58693fc2b1a33051c115d3a289593 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 23 Sep 2026 12:54:18 +0200 Subject: [PATCH 08/13] refactor(layout): inject argument overlay into feature hooks --- .../SupportMagewireEvents.php | 21 +++++++----- .../SupportMagewireLoaders.php | 7 +++- .../LayoutArgumentOverlay.php | 33 ++++++++----------- .../SupportMagewireEventsTest.php | 15 +++++++-- .../SupportMagewireLoadersTest.php | 3 +- .../Mechanisms/LayoutArgumentOverlayTest.php | 19 ++++++++--- 6 files changed, 60 insertions(+), 38 deletions(-) diff --git a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php index 929c8ee2..cbb180bd 100644 --- a/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php +++ b/lib/Magewire/Features/SupportMagewireEvents/SupportMagewireEvents.php @@ -23,7 +23,12 @@ class SupportMagewireEvents extends ComponentHook { - public static function provide(): void + public function __construct( + private readonly LayoutArgumentOverlay $layoutArgumentOverlay + ) { + } + + public function provide(): void { before('call', static function ($component, $method, $params): void { if ($method !== '__dispatch') { @@ -48,9 +53,9 @@ public function boot(): void $component = $this->component(); $fromAttributes = store($component)->get('listenersFromAttributes', []); - store($component)->set('listenersFromAttributes', LayoutArgumentOverlay::apply( - static::normalizeListeners($fromAttributes), - static::normalizeListeners(static::getLayoutListeners($component)), + store($component)->set('listenersFromAttributes', $this->layoutArgumentOverlay->apply( + $this->normalizeListeners($fromAttributes), + $this->normalizeListeners($this->getLayoutListeners($component)), [false, null] )); } @@ -71,14 +76,14 @@ public function dehydrate(ComponentContext $context): void * Return the per-placement listener overlay declared through the * `magewire:listeners` layout argument. */ - protected static function getLayoutListeners($component): array + protected function getLayoutListeners($component): array { - $listeners = LayoutArgumentOverlay::get($component, 'listeners', []); + $listeners = $this->layoutArgumentOverlay->get($component, 'listeners', []); return is_array($listeners) ? $listeners : []; } - protected static function normalizeListeners(array $listeners): array + protected function normalizeListeners(array $listeners): array { $normalized = []; @@ -100,7 +105,7 @@ private function ensureListenerIsNotRemoved(string $listener): void private function getRemovedListenerNames(): array { $component = $this->component(); - $tombstones = LayoutArgumentOverlay::removed(static::normalizeListeners(static::getLayoutListeners($component)), [false, null]); + $tombstones = $this->layoutArgumentOverlay->removed($this->normalizeListeners($this->getLayoutListeners($component)), [false, null]); $tombstones = SupportEvents::replaceDynamicEventNamePlaceholders($tombstones, $component); return array_keys($tombstones); diff --git a/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php index 024d2a79..f17d9092 100644 --- a/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php +++ b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php @@ -19,9 +19,14 @@ class SupportMagewireLoaders extends ComponentHook { + public function __construct( + private readonly LayoutArgumentOverlay $layoutArgumentOverlay + ) { + } + function dehydrate(ComponentContext $context): void { - $loader = LayoutArgumentOverlay::value($context->component, 'loader', $context->component->getLoader(), [null]); + $loader = $this->layoutArgumentOverlay->value($context->component, 'loader', $context->component->getLoader(), [null]); if ($loader) { if (is_array($loader)) { diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php index 4fe49066..3d201c00 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/LayoutArgumentOverlay.php @@ -13,17 +13,11 @@ use Magewirephp\Magewire\Component; -final class LayoutArgumentOverlay +class LayoutArgumentOverlay { - public static function get(Component $component, string $name, mixed $default = null): mixed + public function get(Component $component, string $name, mixed $default = null): mixed { - $resolver = $component->magewireResolver(); - - if ($resolver === null) { - return $default; - } - - $arguments = $resolver->arguments()->all(); + $arguments = $this->arguments($component); return array_key_exists($name, $arguments) ? $arguments[$name] : $default; } @@ -32,24 +26,18 @@ public static function get(Component $component, string $name, mixed $default = * Use the layout value when present. Arrays add entries and replace keys; * selected keyed values can remove entries from the original array. */ - public static function value(Component $component, string $name, mixed $original, array $removals = []): mixed + public function value(Component $component, string $name, mixed $original, array $removals = []): mixed { - $resolver = $component->magewireResolver(); - - if ($resolver === null) { - return $original; - } - - $arguments = $resolver->arguments()->all(); + $arguments = $this->arguments($component); if (! array_key_exists($name, $arguments)) { return $original; } - return self::apply($original, $arguments[$name], $removals); + return $this->apply($original, $arguments[$name], $removals); } - public static function apply(mixed $original, mixed $overlay, array $removals = []): mixed + public function apply(mixed $original, mixed $overlay, array $removals = []): mixed { if (! is_array($overlay)) { return $overlay; @@ -77,8 +65,13 @@ public static function apply(mixed $original, mixed $overlay, array $removals = return $original; } - public static function removed(array $overlay, array $removals): array + public function removed(array $overlay, array $removals): array { return array_filter($overlay, static fn ($value) => in_array($value, $removals, true)); } + + private function arguments(Component $component): array + { + return $component->magewireResolver()?->arguments()->all() ?? []; + } } diff --git a/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php index 90f854af..36c12df4 100644 --- a/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php +++ b/tests/Unit/Features/SupportMagewireEvents/SupportMagewireEventsTest.php @@ -11,9 +11,18 @@ class SupportMagewireEventsTest extends TestCase { + private LayoutArgumentOverlay $layoutArgumentOverlay; + + protected function setUp(): void + { + parent::setUp(); + + $this->layoutArgumentOverlay = new LayoutArgumentOverlay(); + } + public function test_it_layers_layout_handlers_over_attribute_listeners(): void { - $listeners = LayoutArgumentOverlay::apply( + $listeners = $this->layoutArgumentOverlay->apply( $this->normalizeListeners([ 'shorthandListener', 'attribute:removed' => 'onAttributeRemoved', @@ -39,7 +48,7 @@ public function test_it_layers_layout_handlers_over_attribute_listeners(): void public function test_false_and_null_are_preserved_as_listener_tombstones(): void { - $tombstones = LayoutArgumentOverlay::removed($this->normalizeListeners([ + $tombstones = $this->layoutArgumentOverlay->removed($this->normalizeListeners([ 'class:removed' => false, 'class:null-removed' => null, 'layout:kept' => 'onLayoutKept' @@ -58,6 +67,6 @@ private function normalizeListeners(array $listeners): array { $method = new ReflectionMethod(SupportMagewireEvents::class, 'normalizeListeners'); - return $method->invoke(null, $listeners); + return $method->invoke(new SupportMagewireEvents($this->layoutArgumentOverlay), $listeners); } } diff --git a/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php b/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php index ae0560a8..6e883510 100644 --- a/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php +++ b/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php @@ -9,6 +9,7 @@ use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext; use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext\Effects; use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext\Memo; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\LayoutArgumentOverlay; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\MagewireArguments; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentResolver\ComponentResolver; use PHPUnit\Framework\TestCase; @@ -38,7 +39,7 @@ public function test_it_dehydrates_the_layout_overlay_without_changing_the_compo $component->magewireResolver($resolver); $context = new ComponentContext(null, $component, true, new Effects(), new Memo()); - $hook = new SupportMagewireLoaders(); + $hook = new SupportMagewireLoaders(new LayoutArgumentOverlay()); $hook->setComponent($component); $hook->dehydrate($context); diff --git a/tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php b/tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php index ae837dff..c1e308aa 100644 --- a/tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php +++ b/tests/Unit/Mechanisms/LayoutArgumentOverlayTest.php @@ -12,6 +12,15 @@ class LayoutArgumentOverlayTest extends TestCase { + private LayoutArgumentOverlay $layoutArgumentOverlay; + + protected function setUp(): void + { + parent::setUp(); + + $this->layoutArgumentOverlay = new LayoutArgumentOverlay(); + } + public function test_it_appends_list_values_and_replaces_or_removes_named_values(): void { self::assertSame( @@ -20,7 +29,7 @@ public function test_it_appends_list_values_and_replaces_or_removes_named_values 'save' => 'Layout saving', 'layoutAction' ], - LayoutArgumentOverlay::apply( + $this->layoutArgumentOverlay->apply( [ 'classAction', 'save' => 'Class saving', @@ -38,26 +47,26 @@ public function test_it_appends_list_values_and_replaces_or_removes_named_values public function test_it_preserves_false_when_the_consumer_only_removes_null(): void { - self::assertSame(['save' => false], LayoutArgumentOverlay::apply(['save' => 'Class saving'], ['save' => false], [null])); + self::assertSame(['save' => false], $this->layoutArgumentOverlay->apply(['save' => 'Class saving'], ['save' => false], [null])); } public function test_it_discards_removals_when_an_array_replaces_a_scalar(): void { - self::assertSame(['save' => 'Layout saving'], LayoutArgumentOverlay::apply(false, ['save' => 'Layout saving', 'obsolete' => null], [null])); + self::assertSame(['save' => 'Layout saving'], $this->layoutArgumentOverlay->apply(false, ['save' => 'Layout saving', 'obsolete' => null], [null])); } public function test_it_uses_a_layout_scalar_even_when_it_is_null(): void { $component = $this->componentWithArguments(['loader' => null]); - self::assertNull(LayoutArgumentOverlay::value($component, 'loader', ['save' => 'Class saving'])); + self::assertNull($this->layoutArgumentOverlay->value($component, 'loader', ['save' => 'Class saving'])); } public function test_it_leaves_the_original_value_when_the_argument_is_absent(): void { $component = $this->componentWithArguments([]); - self::assertSame(['save'], LayoutArgumentOverlay::value($component, 'loader', ['save'])); + self::assertSame(['save'], $this->layoutArgumentOverlay->value($component, 'loader', ['save'])); } private function componentWithArguments(array $values): Component From 6bbaa2624901dc3d0652d6677123e535df31a6bd Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 23 Sep 2026 13:40:48 +0200 Subject: [PATCH 09/13] feat(layout): support PHP argument modifiers --- .../HandlesMagewireLoaders.php | 6 +- .../SupportMagewireLoaders.php | 53 ++++++++--- .../ArgumentModifierInterface.php | 23 +++++ .../ArgumentModifierRunner.php | 35 +++++++ .../ResolveComponents/ResolveComponents.php | 8 +- src/Magewire/Playwright/Events/Basic.php | 10 ++ .../Playwright/Events/ConfigureArguments.php | 36 +++++++ .../layout/magewire_playwright_events.xml | 4 + tests/Playwright/tests/events.spec.js | 17 +++- .../SupportMagewireLoadersTest.php | 29 +++++- .../Mechanisms/ArgumentModifierRunnerTest.php | 93 +++++++++++++++++++ 11 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php create mode 100644 lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierRunner.php create mode 100644 src/Magewire/Playwright/Events/ConfigureArguments.php create mode 100644 tests/Unit/Mechanisms/ArgumentModifierRunnerTest.php diff --git a/lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php b/lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php index 594bea8f..1e82321f 100644 --- a/lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php +++ b/lib/Magewire/Features/SupportMagewireLoaders/HandlesMagewireLoaders.php @@ -11,13 +11,15 @@ namespace Magewirephp\Magewire\Features\SupportMagewireLoaders; +use Magento\Framework\Phrase; + trait HandlesMagewireLoaders { - /** @var bool|array */ + /** @var bool|string|Phrase|array */ protected $loader = false; /** - * @return bool|array + * @return bool|string|Phrase|array */ public function getLoader() { diff --git a/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php index f17d9092..538b6a97 100644 --- a/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php +++ b/lib/Magewire/Features/SupportMagewireLoaders/SupportMagewireLoaders.php @@ -11,6 +11,7 @@ namespace Magewirephp\Magewire\Features\SupportMagewireLoaders; +use Magento\Framework\Phrase; use Magewirephp\Magewire\ComponentHook; use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\LayoutArgumentOverlay; @@ -29,22 +30,46 @@ function dehydrate(ComponentContext $context): void $loader = $this->layoutArgumentOverlay->value($context->component, 'loader', $context->component->getLoader(), [null]); if ($loader) { - if (is_array($loader)) { - $loader = map_with_keys(static function ($value, $key) { - if (is_string($value)) { - $value = [$value]; - } - if (is_array($value)) { - $value = array_map('__', array_filter($value, 'is_string')); - } - - return [$key => $value]; - }, $loader); - } elseif (is_string($loader)) { - $loader = __($loader); + $context->pushEffect('loader', $this->translateLoader($loader)); + } + } + + private function translateLoader(mixed $loader): mixed + { + if (is_string($loader)) { + return __($loader); + } + + if (! is_array($loader)) { + return $loader; + } + + return map_with_keys(fn ($value, $key) => [$key => $this->translateMessages($value)], $loader); + } + + private function translateMessages(mixed $value): mixed + { + if (is_string($value) || $value instanceof Phrase) { + $value = [$value]; + } + + if (! is_array($value)) { + return $value; + } + + $messages = []; + + foreach ($value as $key => $message) { + if ($message instanceof Phrase) { + $messages[$key] = $message; + continue; } - $context->pushEffect('loader', $loader); + if (is_string($message)) { + $messages[$key] = __($message); + } } + + return $messages; } } diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php new file mode 100644 index 00000000..c5762f6b --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php @@ -0,0 +1,23 @@ +get('argumentModifiers', []); + + if (! is_array($modifiers)) { + throw new InvalidArgumentException('The magewire:argument-modifiers layout argument must be an array.'); + } + + foreach ($modifiers as $name => $modifier) { + if (! $modifier instanceof ArgumentModifierInterface) { + throw new InvalidArgumentException(sprintf('Argument modifier "%s" must implement %s.', $name, ArgumentModifierInterface::class)); + } + + $modifier->modify($component, $arguments); + } + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php index 1cac897a..81c0dbd7 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php @@ -17,6 +17,7 @@ use Magewirephp\Magewire\MagewireServiceProvider; use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext; use Magewirephp\Magewire\Mechanisms\HandleRequests\ComponentRequestContext; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\ArgumentModifierRunner; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentResolver\ComponentResolver; use Magewirephp\Magewire\Mechanisms\ResolveComponents\Management\ComponentResolverManager; use Magewirephp\Magewire\Mechanisms\ResolveComponents\Management\LayoutManager; @@ -30,7 +31,8 @@ class ResolveComponents public function __construct( private readonly ComponentResolverManager $componentResolverManagement, private readonly MagewireServiceProvider $magewireServiceProvider, - private readonly LayoutManager $layoutManager + private readonly LayoutManager $layoutManager, + private readonly ArgumentModifierRunner $argumentModifierRunner ) { } @@ -91,7 +93,7 @@ protected function build(callable $builder): callable throw new ComponentNotFoundException(sprintf('Resolver "%s" failed to construct a Magewire component.', $resolver->accessor())); } - return static function () use ($resolver, $block, $lifecycle) { + return function () use ($resolver, $block, $lifecycle) { $resolver->arguments()->reassemble($block); /** @var Component $component */ @@ -102,6 +104,8 @@ protected function build(callable $builder): callable $component->magewireResolver($resolver); $component->magewireLayoutLifecycle($lifecycle); + $this->argumentModifierRunner->run($component, $resolver->arguments()); + $assembly = $resolver->assemble($block, $component); trigger('magewire:component:build', $assembly, $component, $resolver); diff --git a/src/Magewire/Playwright/Events/Basic.php b/src/Magewire/Playwright/Events/Basic.php index d1e3d4e4..dca7b3ec 100644 --- a/src/Magewire/Playwright/Events/Basic.php +++ b/src/Magewire/Playwright/Events/Basic.php @@ -106,4 +106,14 @@ public function onLayoutRemoved(): void { $this->result = 'layout-removed'; } + + public function onModifierAdded(): void + { + $this->result = 'modifier-added'; + } + + public function onModifierReplacement(): void + { + $this->result = 'modifier-replacement'; + } } diff --git a/src/Magewire/Playwright/Events/ConfigureArguments.php b/src/Magewire/Playwright/Events/ConfigureArguments.php new file mode 100644 index 00000000..08cb463b --- /dev/null +++ b/src/Magewire/Playwright/Events/ConfigureArguments.php @@ -0,0 +1,36 @@ +scope !== 'resolved') { + return; + } + + $listeners = $arguments->get('listeners', []); + $listeners['modifier:' . $component->scope] = 'onModifierAdded'; + $listeners['modifier:replace'] = 'onModifierReplacement'; + $arguments->merge(['listeners' => $listeners]); + + $loader = $arguments->get('loader', []); + $loader['onClassKept'] = [__('Loading from PHP modifier')]; + $loader['onModifierAdded'] = [__('Adding from PHP modifier')]; + $arguments->merge(['loader' => $loader]); + } +} diff --git a/src/view/frontend/layout/magewire_playwright_events.xml b/src/view/frontend/layout/magewire_playwright_events.xml index 45071c8e..69565dac 100644 --- a/src/view/frontend/layout/magewire_playwright_events.xml +++ b/src/view/frontend/layout/magewire_playwright_events.xml @@ -22,12 +22,16 @@ onAttributeReplacement + onLayoutOriginal Layout loading Added loading + + Magewirephp\Magewire\Magewire\Playwright\Events\ConfigureArguments + diff --git a/tests/Playwright/tests/events.spec.js b/tests/Playwright/tests/events.spec.js index f00ea7f8..1018fcc8 100644 --- a/tests/Playwright/tests/events.spec.js +++ b/tests/Playwright/tests/events.spec.js @@ -50,6 +50,8 @@ test.describe('Magewire Playwright — Events', () => { 'attribute:replaced', 'layout:added', 'layout:replaced-later', + 'modifier:resolved', + 'modifier:replace', ])); expect(listeners).not.toEqual(expect.arrayContaining([ 'class:removed', @@ -60,17 +62,28 @@ test.describe('Magewire Playwright — Events', () => { ])); }); - test('merges loader entries from the layout with the component loader', async ({ request }) => { + test('merges loader entries from the layout and PHP modifier', async ({ request }) => { const html = await (await request.get(PATH)).text(); const tag = rootTag(html, ID); expect(tag).toBeTruthy(); expect(effectsFromTag(tag).loader[0]).toEqual({ - onClassKept: ['Layout loading'], + onClassKept: ['Loading from PHP modifier'], onLayoutAdded: ['Added loading'], + onModifierAdded: ['Adding from PHP modifier'], }); }); + test('uses PHP-computed listener names and handler replacements', async ({ page }) => { + const result = component(page).getByTestId('event-result'); + + await dispatch(page, 'modifier:resolved'); + await expect(result).toHaveText('modifier-added'); + + await dispatch(page, 'modifier:replace'); + await expect(result).toHaveText('modifier-replacement'); + }); + test('dispatches kept and layout-added listeners', async ({ page }) => { const result = component(page).getByTestId('event-result'); diff --git a/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php b/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php index 6e883510..397110ec 100644 --- a/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php +++ b/tests/Unit/Features/SupportMagewireLoaders/SupportMagewireLoadersTest.php @@ -16,6 +16,26 @@ class SupportMagewireLoadersTest extends TestCase { + public function test_it_preserves_a_phrase_used_as_the_whole_loader(): void + { + $component = new class extends Component {}; + $phrase = __('Global loading'); + + $arguments = $this->createMock(MagewireArguments::class); + $arguments->method('all')->willReturn(['loader' => $phrase]); + + $resolver = $this->createMock(ComponentResolver::class); + $resolver->method('arguments')->willReturn($arguments); + $component->magewireResolver($resolver); + + $context = new ComponentContext(null, $component, true, new Effects(), new Memo()); + $hook = new SupportMagewireLoaders(new LayoutArgumentOverlay()); + $hook->setComponent($component); + $hook->dehydrate($context); + + self::assertSame($phrase, $context->getEffects()->getData('loader')[0]); + } + public function test_it_dehydrates_the_layout_overlay_without_changing_the_component_loader(): void { $component = new class extends Component { @@ -26,12 +46,14 @@ public function test_it_dehydrates_the_layout_overlay_without_changing_the_compo }; $arguments = $this->createMock(MagewireArguments::class); + $phrase = __('Layout publishing'); $arguments ->method('all') ->willReturn(['loader' => [ 'save' => 'Layout saving', 'delete' => null, - 'publish' => 'Layout publishing' + 'publish' => [$phrase], + 'archive' => __('Layout archiving') ]]); $resolver = $this->createMock(ComponentResolver::class); @@ -45,9 +67,10 @@ public function test_it_dehydrates_the_layout_overlay_without_changing_the_compo $loader = $context->getEffects()->getData('loader')[0]; - self::assertSame(['save', 'publish'], array_keys($loader)); + self::assertSame(['save', 'publish', 'archive'], array_keys($loader)); self::assertSame('Layout saving', (string) $loader['save'][0]); - self::assertSame('Layout publishing', (string) $loader['publish'][0]); + self::assertSame($phrase, $loader['publish'][0]); + self::assertSame('Layout archiving', (string) $loader['archive'][0]); self::assertSame(['save' => 'Class saving', 'delete' => 'Class deleting'], $component->getLoader()); } } diff --git a/tests/Unit/Mechanisms/ArgumentModifierRunnerTest.php b/tests/Unit/Mechanisms/ArgumentModifierRunnerTest.php new file mode 100644 index 00000000..18b78af1 --- /dev/null +++ b/tests/Unit/Mechanisms/ArgumentModifierRunnerTest.php @@ -0,0 +1,93 @@ +arguments(['loader' => 'Original']); + + $runner = new ArgumentModifierRunner(); + $runner->run(new Basic(), $arguments); + + self::assertSame('Original', $arguments->get('loader')); + } + + public function test_it_modifies_any_argument_in_xml_order(): void + { + $component = new Basic(); + $arguments = $this->arguments([ + 'argumentModifiers' => [ + new ConfigureArguments(), + new class implements ArgumentModifierInterface { + public function modify(Component $component, MagewireArguments $arguments): void + { + $arguments->merge(['custom' => (string) $arguments->get('loader')['onModifierAdded'][0]]); + } + } + ], + 'listeners' => ['modifier:replace' => 'onLayoutOriginal'], + 'loader' => ['onClassKept' => 'Layout loading'] + ]); + + $runner = new ArgumentModifierRunner(); + $runner->run($component, $arguments); + + self::assertSame('onModifierAdded', $arguments->get('listeners')['modifier:resolved']); + self::assertSame('onModifierReplacement', $arguments->get('listeners')['modifier:replace']); + self::assertSame('Loading from PHP modifier', (string) $arguments->get('loader')['onClassKept'][0]); + self::assertSame('Adding from PHP modifier', $arguments->get('custom')); + } + + public function test_it_respects_conditions_in_the_modifier(): void + { + $component = new Basic(); + $component->scope = 'other'; + $arguments = $this->arguments([ + 'argumentModifiers' => [new ConfigureArguments()], + 'listeners' => ['modifier:replace' => 'onLayoutOriginal'] + ]); + + $runner = new ArgumentModifierRunner(); + $runner->run($component, $arguments); + + self::assertSame(['modifier:replace' => 'onLayoutOriginal'], $arguments->get('listeners')); + } + + public function test_it_requires_an_array_of_argument_modifiers(): void + { + $arguments = $this->arguments(['argumentModifiers' => 'invalid']); + + $this->expectException(InvalidArgumentException::class); + $runner = new ArgumentModifierRunner(); + $runner->run(new Basic(), $arguments); + } + + public function test_it_rejects_an_invalid_modifier_entry(): void + { + $arguments = $this->arguments(['argumentModifiers' => ['invalid' => new \stdClass()]]); + + $this->expectException(InvalidArgumentException::class); + $runner = new ArgumentModifierRunner(); + $runner->run(new Basic(), $arguments); + } + + private function arguments(array $values): BlockMagewireArguments + { + return new BlockMagewireArguments($this->createMock(Filter::class), $values); + } +} From 4fa12574368c3a289270ffc1a609bea50b4cc49a Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 23 Sep 2026 14:04:55 +0200 Subject: [PATCH 10/13] fix(layout): accept modifiers as Magento block arguments --- .../ComponentArguments/ArgumentModifierInterface.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php index c5762f6b..326ab9f4 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php @@ -11,13 +11,14 @@ namespace Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments; +use Magento\Framework\View\Element\Block\ArgumentInterface; use Magewirephp\Magewire\Component; /** * Changes resolved Magewire arguments before component mount or hydration. * The collection accepts any argument name, including future ones. */ -interface ArgumentModifierInterface +interface ArgumentModifierInterface extends ArgumentInterface { public function modify(Component $component, MagewireArguments $arguments): void; } From 272031aeab0eaefebf5c6ccd3ae0125d47296323 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 23 Sep 2026 16:04:20 +0200 Subject: [PATCH 11/13] refactor(layout): make modifiers component scoped --- .../ArgumentModifierRunner.php | 35 ------------- .../ComponentModifierContext.php | 34 +++++++++++++ .../ComponentModifierInterface.php} | 10 ++-- .../ComponentModifierRunner.php | 34 +++++++++++++ .../ResolveComponents/ResolveComponents.php | 7 +-- ...reArguments.php => ConfigureComponent.php} | 12 +++-- .../layout/magewire_playwright_events.xml | 4 +- ...st.php => ComponentModifierRunnerTest.php} | 50 ++++++++++--------- 8 files changed, 111 insertions(+), 75 deletions(-) delete mode 100644 lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierRunner.php create mode 100644 lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php rename lib/Magewire/Mechanisms/ResolveComponents/{ComponentArguments/ArgumentModifierInterface.php => ComponentModifiers/ComponentModifierInterface.php} (50%) create mode 100644 lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php rename src/Magewire/Playwright/Events/{ConfigureArguments.php => ConfigureComponent.php} (67%) rename tests/Unit/Mechanisms/{ArgumentModifierRunnerTest.php => ComponentModifierRunnerTest.php} (56%) diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierRunner.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierRunner.php deleted file mode 100644 index 94cf3aa3..00000000 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierRunner.php +++ /dev/null @@ -1,35 +0,0 @@ -get('argumentModifiers', []); - - if (! is_array($modifiers)) { - throw new InvalidArgumentException('The magewire:argument-modifiers layout argument must be an array.'); - } - - foreach ($modifiers as $name => $modifier) { - if (! $modifier instanceof ArgumentModifierInterface) { - throw new InvalidArgumentException(sprintf('Argument modifier "%s" must implement %s.', $name, ArgumentModifierInterface::class)); - } - - $modifier->modify($component, $arguments); - } - } -} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php new file mode 100644 index 00000000..ddb79ff2 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php @@ -0,0 +1,34 @@ +component; + } + + public function getArguments(): MagewireArguments + { + return $this->arguments; + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierInterface.php similarity index 50% rename from lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php rename to lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierInterface.php index 326ab9f4..1b37d5df 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentArguments/ArgumentModifierInterface.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierInterface.php @@ -9,16 +9,14 @@ declare(strict_types=1); -namespace Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments; +namespace Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers; use Magento\Framework\View\Element\Block\ArgumentInterface; -use Magewirephp\Magewire\Component; /** - * Changes resolved Magewire arguments before component mount or hydration. - * The collection accepts any argument name, including future ones. + * Changes a component during its build, before mount or hydration. */ -interface ArgumentModifierInterface extends ArgumentInterface +interface ComponentModifierInterface extends ArgumentInterface { - public function modify(Component $component, MagewireArguments $arguments): void; + public function modify(ComponentModifierContext $context): void; } diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php new file mode 100644 index 00000000..f44fad18 --- /dev/null +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php @@ -0,0 +1,34 @@ +getArguments()->get('modifiers', []); + + if (! is_array($modifiers)) { + throw new InvalidArgumentException('The magewire:modifiers layout argument must be an array.'); + } + + foreach ($modifiers as $name => $modifier) { + if (! $modifier instanceof ComponentModifierInterface) { + throw new InvalidArgumentException(sprintf('Component modifier "%s" must implement %s.', $name, ComponentModifierInterface::class)); + } + + $modifier->modify($context); + } + } +} diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php index 81c0dbd7..73383a67 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ResolveComponents.php @@ -17,7 +17,8 @@ use Magewirephp\Magewire\MagewireServiceProvider; use Magewirephp\Magewire\Mechanisms\HandleComponents\ComponentContext; use Magewirephp\Magewire\Mechanisms\HandleRequests\ComponentRequestContext; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\ArgumentModifierRunner; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierContext; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierRunner; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentResolver\ComponentResolver; use Magewirephp\Magewire\Mechanisms\ResolveComponents\Management\ComponentResolverManager; use Magewirephp\Magewire\Mechanisms\ResolveComponents\Management\LayoutManager; @@ -32,7 +33,7 @@ public function __construct( private readonly ComponentResolverManager $componentResolverManagement, private readonly MagewireServiceProvider $magewireServiceProvider, private readonly LayoutManager $layoutManager, - private readonly ArgumentModifierRunner $argumentModifierRunner + private readonly ComponentModifierRunner $componentModifierRunner ) { } @@ -104,7 +105,7 @@ protected function build(callable $builder): callable $component->magewireResolver($resolver); $component->magewireLayoutLifecycle($lifecycle); - $this->argumentModifierRunner->run($component, $resolver->arguments()); + $this->componentModifierRunner->run(new ComponentModifierContext($component, $resolver->arguments())); $assembly = $resolver->assemble($block, $component); trigger('magewire:component:build', $assembly, $component, $resolver); diff --git a/src/Magewire/Playwright/Events/ConfigureArguments.php b/src/Magewire/Playwright/Events/ConfigureComponent.php similarity index 67% rename from src/Magewire/Playwright/Events/ConfigureArguments.php rename to src/Magewire/Playwright/Events/ConfigureComponent.php index 08cb463b..44876c69 100644 --- a/src/Magewire/Playwright/Events/ConfigureArguments.php +++ b/src/Magewire/Playwright/Events/ConfigureComponent.php @@ -11,18 +11,20 @@ namespace Magewirephp\Magewire\Magewire\Playwright\Events; -use Magewirephp\Magewire\Component; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\ArgumentModifierInterface; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\MagewireArguments; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierContext; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierInterface; -class ConfigureArguments implements ArgumentModifierInterface +class ConfigureComponent implements ComponentModifierInterface { - public function modify(Component $component, MagewireArguments $arguments): void + public function modify(ComponentModifierContext $context): void { + $component = $context->getComponent(); + if (! $component instanceof Basic || $component->scope !== 'resolved') { return; } + $arguments = $context->getArguments(); $listeners = $arguments->get('listeners', []); $listeners['modifier:' . $component->scope] = 'onModifierAdded'; $listeners['modifier:replace'] = 'onModifierReplacement'; diff --git a/src/view/frontend/layout/magewire_playwright_events.xml b/src/view/frontend/layout/magewire_playwright_events.xml index 69565dac..cb8a3a9d 100644 --- a/src/view/frontend/layout/magewire_playwright_events.xml +++ b/src/view/frontend/layout/magewire_playwright_events.xml @@ -29,8 +29,8 @@ Added loading - - Magewirephp\Magewire\Magewire\Playwright\Events\ConfigureArguments + + Magewirephp\Magewire\Magewire\Playwright\Events\ConfigureComponent diff --git a/tests/Unit/Mechanisms/ArgumentModifierRunnerTest.php b/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php similarity index 56% rename from tests/Unit/Mechanisms/ArgumentModifierRunnerTest.php rename to tests/Unit/Mechanisms/ComponentModifierRunnerTest.php index 18b78af1..85eb5c6b 100644 --- a/tests/Unit/Mechanisms/ArgumentModifierRunnerTest.php +++ b/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php @@ -5,24 +5,23 @@ namespace Magewirephp\Magewire\Tests\Unit\Mechanisms; use InvalidArgumentException; -use Magewirephp\Magewire\Component; use Magewirephp\Magewire\Magewire\Playwright\Events\Basic; -use Magewirephp\Magewire\Magewire\Playwright\Events\ConfigureArguments; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\ArgumentModifierInterface; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\ArgumentModifierRunner; +use Magewirephp\Magewire\Magewire\Playwright\Events\ConfigureComponent; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\BlockMagewireArguments; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\MagewireArguments; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierContext; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierInterface; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierRunner; use Magewirephp\Magewire\Support\DataCollection\Filter; use PHPUnit\Framework\TestCase; -class ArgumentModifierRunnerTest extends TestCase +class ComponentModifierRunnerTest extends TestCase { public function test_it_leaves_arguments_without_modifiers_unchanged(): void { $arguments = $this->arguments(['loader' => 'Original']); - $runner = new ArgumentModifierRunner(); - $runner->run(new Basic(), $arguments); + $runner = new ComponentModifierRunner(); + $runner->run(new ComponentModifierContext(new Basic(), $arguments)); self::assertSame('Original', $arguments->get('loader')); } @@ -31,12 +30,14 @@ public function test_it_modifies_any_argument_in_xml_order(): void { $component = new Basic(); $arguments = $this->arguments([ - 'argumentModifiers' => [ - new ConfigureArguments(), - new class implements ArgumentModifierInterface { - public function modify(Component $component, MagewireArguments $arguments): void + 'modifiers' => [ + new ConfigureComponent(), + new class implements ComponentModifierInterface { + public function modify(ComponentModifierContext $context): void { + $arguments = $context->getArguments(); $arguments->merge(['custom' => (string) $arguments->get('loader')['onModifierAdded'][0]]); + $context->getComponent()->scope = 'modified'; } } ], @@ -44,13 +45,14 @@ public function modify(Component $component, MagewireArguments $arguments): void 'loader' => ['onClassKept' => 'Layout loading'] ]); - $runner = new ArgumentModifierRunner(); - $runner->run($component, $arguments); + $runner = new ComponentModifierRunner(); + $runner->run(new ComponentModifierContext($component, $arguments)); self::assertSame('onModifierAdded', $arguments->get('listeners')['modifier:resolved']); self::assertSame('onModifierReplacement', $arguments->get('listeners')['modifier:replace']); self::assertSame('Loading from PHP modifier', (string) $arguments->get('loader')['onClassKept'][0]); self::assertSame('Adding from PHP modifier', $arguments->get('custom')); + self::assertSame('modified', $component->scope); } public function test_it_respects_conditions_in_the_modifier(): void @@ -58,32 +60,32 @@ public function test_it_respects_conditions_in_the_modifier(): void $component = new Basic(); $component->scope = 'other'; $arguments = $this->arguments([ - 'argumentModifiers' => [new ConfigureArguments()], + 'modifiers' => [new ConfigureComponent()], 'listeners' => ['modifier:replace' => 'onLayoutOriginal'] ]); - $runner = new ArgumentModifierRunner(); - $runner->run($component, $arguments); + $runner = new ComponentModifierRunner(); + $runner->run(new ComponentModifierContext($component, $arguments)); self::assertSame(['modifier:replace' => 'onLayoutOriginal'], $arguments->get('listeners')); } - public function test_it_requires_an_array_of_argument_modifiers(): void + public function test_it_requires_an_array_of_modifiers(): void { - $arguments = $this->arguments(['argumentModifiers' => 'invalid']); + $arguments = $this->arguments(['modifiers' => 'invalid']); $this->expectException(InvalidArgumentException::class); - $runner = new ArgumentModifierRunner(); - $runner->run(new Basic(), $arguments); + $runner = new ComponentModifierRunner(); + $runner->run(new ComponentModifierContext(new Basic(), $arguments)); } public function test_it_rejects_an_invalid_modifier_entry(): void { - $arguments = $this->arguments(['argumentModifiers' => ['invalid' => new \stdClass()]]); + $arguments = $this->arguments(['modifiers' => ['invalid' => new \stdClass()]]); $this->expectException(InvalidArgumentException::class); - $runner = new ArgumentModifierRunner(); - $runner->run(new Basic(), $arguments); + $runner = new ComponentModifierRunner(); + $runner->run(new ComponentModifierContext(new Basic(), $arguments)); } private function arguments(array $values): BlockMagewireArguments From b52a07d70288e3cfd2cee7a00c320240f57892d3 Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 23 Sep 2026 16:39:09 +0200 Subject: [PATCH 12/13] refactor(layout): shorten modifier interface name --- .../ComponentModifiers/ComponentModifierRunner.php | 4 ++-- .../{ComponentModifierInterface.php => ModifierInterface.php} | 2 +- src/Magewire/Playwright/Events/ConfigureComponent.php | 4 ++-- tests/Unit/Mechanisms/ComponentModifierRunnerTest.php | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) rename lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/{ComponentModifierInterface.php => ModifierInterface.php} (88%) diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php index f44fad18..8bf77e8d 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php @@ -24,8 +24,8 @@ public function run(ComponentModifierContext $context): void } foreach ($modifiers as $name => $modifier) { - if (! $modifier instanceof ComponentModifierInterface) { - throw new InvalidArgumentException(sprintf('Component modifier "%s" must implement %s.', $name, ComponentModifierInterface::class)); + if (! $modifier instanceof ModifierInterface) { + throw new InvalidArgumentException(sprintf('Component modifier "%s" must implement %s.', $name, ModifierInterface::class)); } $modifier->modify($context); diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierInterface.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ModifierInterface.php similarity index 88% rename from lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierInterface.php rename to lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ModifierInterface.php index 1b37d5df..05143324 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierInterface.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ModifierInterface.php @@ -16,7 +16,7 @@ /** * Changes a component during its build, before mount or hydration. */ -interface ComponentModifierInterface extends ArgumentInterface +interface ModifierInterface extends ArgumentInterface { public function modify(ComponentModifierContext $context): void; } diff --git a/src/Magewire/Playwright/Events/ConfigureComponent.php b/src/Magewire/Playwright/Events/ConfigureComponent.php index 44876c69..d6a56107 100644 --- a/src/Magewire/Playwright/Events/ConfigureComponent.php +++ b/src/Magewire/Playwright/Events/ConfigureComponent.php @@ -12,9 +12,9 @@ namespace Magewirephp\Magewire\Magewire\Playwright\Events; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierContext; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierInterface; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ModifierInterface; -class ConfigureComponent implements ComponentModifierInterface +class ConfigureComponent implements ModifierInterface { public function modify(ComponentModifierContext $context): void { diff --git a/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php b/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php index 85eb5c6b..87487b5e 100644 --- a/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php +++ b/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php @@ -9,8 +9,8 @@ use Magewirephp\Magewire\Magewire\Playwright\Events\ConfigureComponent; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentArguments\BlockMagewireArguments; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierContext; -use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierInterface; use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ComponentModifierRunner; +use Magewirephp\Magewire\Mechanisms\ResolveComponents\ComponentModifiers\ModifierInterface; use Magewirephp\Magewire\Support\DataCollection\Filter; use PHPUnit\Framework\TestCase; @@ -32,7 +32,7 @@ public function test_it_modifies_any_argument_in_xml_order(): void $arguments = $this->arguments([ 'modifiers' => [ new ConfigureComponent(), - new class implements ComponentModifierInterface { + new class implements ModifierInterface { public function modify(ComponentModifierContext $context): void { $arguments = $context->getArguments(); From 7e72344330afd8a0096f7d667f22932e8a6a1f3f Mon Sep 17 00:00:00 2001 From: Willem Poortman Date: Wed, 23 Sep 2026 20:51:41 +0200 Subject: [PATCH 13/13] refactor(layout): use object-style modifier context accessors --- .../ComponentModifiers/ComponentModifierContext.php | 4 ++-- .../ComponentModifiers/ComponentModifierRunner.php | 2 +- src/Magewire/Playwright/Events/ConfigureComponent.php | 4 ++-- tests/Playwright/tests/events.spec.js | 6 +++--- tests/Unit/Mechanisms/ComponentModifierRunnerTest.php | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php index ddb79ff2..b90de5f2 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierContext.php @@ -22,12 +22,12 @@ public function __construct( ) { } - public function getComponent(): Component + public function component(): Component { return $this->component; } - public function getArguments(): MagewireArguments + public function arguments(): MagewireArguments { return $this->arguments; } diff --git a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php index 8bf77e8d..18ad62db 100644 --- a/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php +++ b/lib/Magewire/Mechanisms/ResolveComponents/ComponentModifiers/ComponentModifierRunner.php @@ -17,7 +17,7 @@ class ComponentModifierRunner { public function run(ComponentModifierContext $context): void { - $modifiers = $context->getArguments()->get('modifiers', []); + $modifiers = $context->arguments()->get('modifiers', []); if (! is_array($modifiers)) { throw new InvalidArgumentException('The magewire:modifiers layout argument must be an array.'); diff --git a/src/Magewire/Playwright/Events/ConfigureComponent.php b/src/Magewire/Playwright/Events/ConfigureComponent.php index d6a56107..93979ac8 100644 --- a/src/Magewire/Playwright/Events/ConfigureComponent.php +++ b/src/Magewire/Playwright/Events/ConfigureComponent.php @@ -18,13 +18,13 @@ class ConfigureComponent implements ModifierInterface { public function modify(ComponentModifierContext $context): void { - $component = $context->getComponent(); + $component = $context->component(); if (! $component instanceof Basic || $component->scope !== 'resolved') { return; } - $arguments = $context->getArguments(); + $arguments = $context->arguments(); $listeners = $arguments->get('listeners', []); $listeners['modifier:' . $component->scope] = 'onModifierAdded'; $listeners['modifier:replace'] = 'onModifierReplacement'; diff --git a/tests/Playwright/tests/events.spec.js b/tests/Playwright/tests/events.spec.js index 1018fcc8..1e56e588 100644 --- a/tests/Playwright/tests/events.spec.js +++ b/tests/Playwright/tests/events.spec.js @@ -24,7 +24,7 @@ async function dispatch(page, event) { await page.evaluate((name) => window.dispatchEvent(new CustomEvent(name)), event); } -test.describe('Magewire Playwright — Events', () => { +test.describe('Magewire Playwright — Events, Loaders and Modifiers', () => { test.beforeEach(async ({ page }) => { const version = Math.floor(Math.random() * 1_000_000); await page.goto(`${PATH}?v=${version}`); @@ -62,7 +62,7 @@ test.describe('Magewire Playwright — Events', () => { ])); }); - test('merges loader entries from the layout and PHP modifier', async ({ request }) => { + test('merges loader entries from the layout and modifier object', async ({ request }) => { const html = await (await request.get(PATH)).text(); const tag = rootTag(html, ID); @@ -74,7 +74,7 @@ test.describe('Magewire Playwright — Events', () => { }); }); - test('uses PHP-computed listener names and handler replacements', async ({ page }) => { + test('uses the modifier object for dynamic listener names and handler replacements', async ({ page }) => { const result = component(page).getByTestId('event-result'); await dispatch(page, 'modifier:resolved'); diff --git a/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php b/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php index 87487b5e..47556884 100644 --- a/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php +++ b/tests/Unit/Mechanisms/ComponentModifierRunnerTest.php @@ -35,9 +35,9 @@ public function test_it_modifies_any_argument_in_xml_order(): void new class implements ModifierInterface { public function modify(ComponentModifierContext $context): void { - $arguments = $context->getArguments(); + $arguments = $context->arguments(); $arguments->merge(['custom' => (string) $arguments->get('loader')['onModifierAdded'][0]]); - $context->getComponent()->scope = 'modified'; + $context->component()->scope = 'modified'; } } ],