Repository: clickfwd/yoyo Branch: develop Commit: f8c14d260df0 Files: 249 Total size: 436.6 KB Directory structure: gitextract_fxf4xndl/ ├── .actrc ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── package.json ├── phpunit.xml ├── src/ │ ├── assets/ │ │ └── js/ │ │ └── yoyo.js │ └── yoyo/ │ ├── AnonymousComponent.php │ ├── Blade/ │ │ ├── Application.php │ │ ├── CreateBladeViewFromString.php │ │ ├── YoyoBladeCompilerEngine.php │ │ ├── YoyoBladeDirectives.php │ │ ├── YoyoServiceProvider.php │ │ └── yoyo-view.blade.php │ ├── ClassHelpers.php │ ├── Component.php │ ├── ComponentManager.php │ ├── ComponentResolver.php │ ├── Concerns/ │ │ ├── BrowserEvents.php │ │ ├── Redirector.php │ │ ├── ResponseHeaders.php │ │ └── Singleton.php │ ├── ContainerResolver.php │ ├── Containers/ │ │ ├── IlluminateContainer.php │ │ └── YoyoContainer.php │ ├── Exceptions/ │ │ ├── BindingNotFoundException.php │ │ ├── BypassRenderMethod.php │ │ ├── ComponentMethodNotFound.php │ │ ├── ComponentNotFound.php │ │ ├── ContainerResolutionException.php │ │ ├── FailedToRegisterComponent.php │ │ ├── HttpException.php │ │ ├── IncompleteComponentParamInRequest.php │ │ ├── MissingComponentTemplate.php │ │ ├── NonPublicComponentMethodCall.php │ │ └── NotFoundHttpException.php │ ├── Interfaces/ │ │ ├── RequestInterface.php │ │ ├── ViewProviderInterface.php │ │ └── YoyoContainerInterface.php │ ├── InvocableComponentVariable.php │ ├── QueryString.php │ ├── Request.php │ ├── Services/ │ │ ├── BrowserEventsService.php │ │ ├── Configuration.php │ │ ├── PageRedirectService.php │ │ ├── Response.php │ │ └── UrlStateManagerService.php │ ├── Twig/ │ │ └── YoyoTwigExtension.php │ ├── View.php │ ├── ViewProviders/ │ │ ├── BaseViewProvider.php │ │ ├── BladeViewProvider.php │ │ ├── PhalconViewProvider.php │ │ ├── TwigViewProvider.php │ │ └── YoyoViewProvider.php │ ├── Yoyo.php │ ├── YoyoCompiler.php │ ├── YoyoHelpers.php │ ├── YoyoPhalconController.php │ ├── YoyoPhalconServiceProvider.php │ └── helpers.php └── tests/ ├── Benchmark/ │ ├── PipelineBenchmarkTest.php │ ├── RealWorldBenchmarkTest.php │ ├── YoyoCompilerBenchmarkTest.php │ └── profile-compiler.php ├── Browser/ │ ├── BrowserServer.php │ ├── Components/ │ │ ├── ActionButton.php │ │ ├── Counter.php │ │ ├── DeleteItem.php │ │ ├── DispatchBystander.php │ │ ├── DispatchListener.php │ │ ├── FavoriteButton.php │ │ ├── Form.php │ │ ├── LiveSearch.php │ │ ├── ModalTrigger.php │ │ ├── MultiScreen.php │ │ ├── NotificationBadge.php │ │ ├── NullProp.php │ │ ├── Pagination.php │ │ ├── ResponseHeaders.php │ │ ├── StatusDropdown.php │ │ └── TodoList.php │ ├── CounterTest.php │ ├── CrossComponentEventsTest.php │ ├── DispatchTest.php │ ├── FormTest.php │ ├── InfrastructureTest.php │ ├── LiveSearchTest.php │ ├── ModalTest.php │ ├── MultiScreenTest.php │ ├── NullPropTest.php │ ├── PaginationTest.php │ ├── ProductListTest.php │ ├── ResponseHeadersTest.php │ ├── SkipRenderTest.php │ ├── TodoListTest.php │ ├── bootstrap.php │ └── server/ │ ├── index.php │ ├── layout.php │ ├── pages/ │ │ ├── counter.php │ │ ├── dispatch.php │ │ ├── events.php │ │ ├── form.php │ │ ├── index.php │ │ ├── live-search.php │ │ ├── modal.php │ │ ├── multi-screen.php │ │ ├── null-prop.php │ │ ├── pagination.php │ │ ├── product-list.php │ │ ├── response-headers.php │ │ ├── skip-render.php │ │ └── todo-list.php │ └── views/ │ ├── action-button.php │ ├── counter.php │ ├── delete-item.php │ ├── dispatch-bystander.php │ ├── dispatch-listener.php │ ├── favorite-button.php │ ├── form.php │ ├── live-search.php │ ├── modal-trigger.php │ ├── multi-screen.php │ ├── notification-badge.php │ ├── null-prop.php │ ├── pagination.php │ ├── response-headers.php │ ├── status-dropdown.php │ └── todo-list.php ├── Feature/ │ ├── AnonymousComponentTest.php │ ├── BladeTest.php │ ├── ComponentLifecycleTest.php │ ├── ComponentResolverTest.php │ ├── DispatchEventTest.php │ ├── DynamicComponentTest.php │ ├── NamespacedComponentTest.php │ ├── NestedComponentTest.php │ ├── ResponseHeadersComponentTest.php │ └── TwigTest.php ├── Helpers.php ├── HelpersBlade.php ├── HelpersTwig.php ├── InitYoyoContainer.php ├── Pest.php ├── Unit/ │ ├── BrowserEventsServiceTest.php │ ├── BrowserEventsTest.php │ ├── ClassHelpersTest.php │ ├── ComponentResolverTest.php │ ├── ComponentTest.php │ ├── ConfigurationTest.php │ ├── ContainerResolverTest.php │ ├── ExceptionsTest.php │ ├── IlluminateContainerTest.php │ ├── InvocableComponentVariableTest.php │ ├── PageRedirectServiceTest.php │ ├── ProtectedMethodTest.php │ ├── PushUrlStateTest.php │ ├── QueryStringTest.php │ ├── RegressionTest.php │ ├── RequestTest.php │ ├── ResponseHeadersTest.php │ ├── SecurityTest.php │ ├── UrlStateManagerServiceTest.php │ ├── ViewTest.php │ ├── YoyoCompileTest.php │ ├── YoyoCompilerEdgeCasesTest.php │ ├── YoyoContainerTest.php │ ├── YoyoHelpersTest.php │ └── YoyoViewProviderTest.php ├── app/ │ ├── Comment.php │ ├── Post.php │ ├── Resolvers/ │ │ ├── BladeComponentResolver.php │ │ ├── CustomComponentResolver.php │ │ └── TwigComponentResolver.php │ ├── Yoyo/ │ │ ├── Abort.php │ │ ├── Account/ │ │ │ └── Register.php │ │ ├── ActionArguments.php │ │ ├── ComponentWithComputedArgs.php │ │ ├── ComponentWithEmit.php │ │ ├── ComponentWithListeners.php │ │ ├── ComponentWithRedirect.php │ │ ├── ComponentWithResponseHeaders.php │ │ ├── ComponentWithSwapModifiers.php │ │ ├── ComponentWithTrait.php │ │ ├── ComputedProperty.php │ │ ├── ComputedPropertyCache.php │ │ ├── Counter.php │ │ ├── CounterDynamicProperties.php │ │ ├── DependencyInjectionAction.php │ │ ├── DependencyInjectionClassWithNamedArgumentMapping.php │ │ ├── DispatchListener.php │ │ ├── EmptyResponse.php │ │ ├── EmptyResponseAndRemove.php │ │ ├── ProtectedMethods.php │ │ ├── Registered.php │ │ ├── SetViewData.php │ │ └── VariadicParameters.php │ └── resources/ │ └── views/ │ ├── components/ │ │ └── select.blade.php │ └── yoyo/ │ ├── account/ │ │ ├── login.blade.php │ │ ├── login.php │ │ ├── register.blade.php │ │ └── register.php │ ├── action-arguments.php │ ├── child.blade.php │ ├── child.php │ ├── child.twig │ ├── component-with-computed-args.php │ ├── component-with-emit.php │ ├── component-with-listeners.php │ ├── component-with-redirect.php │ ├── component-with-response-headers.php │ ├── component-with-swap-modifiers.php │ ├── component-with-trait.php │ ├── computed-property-cache.php │ ├── computed-property.php │ ├── counter.php │ ├── dependency-injection-action.php │ ├── dependency-injection-class-with-named-argument-mapping.php │ ├── dispatch-listener.php │ ├── foo.blade.php │ ├── foo.php │ ├── foo.twig │ ├── layout-a.blade.php │ ├── layout-b.blade.php │ ├── parent.blade.php │ ├── parent.php │ ├── parent.twig │ ├── registered-anon.php │ ├── registered.php │ ├── set-view-data.php │ └── variadic-parameters.php ├── app-another/ │ ├── Yoyo/ │ │ └── Counter.php │ └── views/ │ ├── components/ │ │ └── input.blade.php │ ├── counter.blade.php │ ├── counter.php │ ├── counter.twig │ ├── foo.blade.php │ ├── foo.php │ └── foo.twig ├── compiled/ │ └── .gitkeep └── responses/ ├── action-arguments.html ├── computed-property-cache.html ├── computed-property.html ├── nested.blade.html ├── nested.html └── nested.twig.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .actrc ================================================ # For runs-on: ubuntu-latest -P ubuntu-latest=shivammathur/node:latest ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: pull_request: jobs: style: if: github.event_name == 'push' runs-on: ubuntu-latest name: Code Style steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.3 - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress - name: Run PHP CS Fixer run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Fix styling unit: runs-on: ubuntu-latest strategy: fail-fast: true matrix: php: [ 8.4, 8.3 ] name: Unit & Feature — PHP ${{ matrix.php }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - name: Get Composer cache dir id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.lock') }} restore-keys: php-${{ matrix.php }}-composer- - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress - name: Run unit & feature tests run: vendor/bin/pest --testsuite="Unit Suite,Feature Suite" browser: runs-on: ubuntu-latest name: Browser E2E steps: - name: Checkout uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.3 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: npm cache-dependency-path: package-lock.json - name: Get Composer cache dir id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: php-8.3-composer-${{ hashFiles('composer.lock') }} restore-keys: php-8.3-composer- - name: Install PHP dependencies run: composer install --prefer-dist --no-interaction --no-progress - name: Cache Playwright browsers uses: actions/cache@v4 id: playwright-cache with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}-chromium restore-keys: ${{ runner.os }}-playwright- - name: Install Playwright run: npm ci && npx playwright install --with-deps chromium - name: Run browser tests run: vendor/bin/pest --group=browser ================================================ FILE: .gitignore ================================================ /vendor /node_modules /.phpunit.result.cache /.php-cs-fixer.cache /tests/compiled/**/*.php /tests/compiled/*.php /tests/Browser/Screenshots /tests/Benchmark/profile-report.html .claude ================================================ FILE: .php-cs-fixer.dist.php ================================================ name('*.php') ->notName('*.blade.php') ->ignoreDotFiles(true) ->ignoreVCS(true) ->in([ __DIR__ . '/src', __DIR__ . '/tests', ]); $config = new PhpCsFixer\Config(); return $config->setRules([ '@PSR12' => true, 'array_syntax' => ['syntax' => 'short'], 'ordered_imports' => ['sort_algorithm' => 'alpha'], 'no_unused_imports' => true, 'not_operator_with_successor_space' => true, 'trailing_comma_in_multiline' => true, 'phpdoc_scalar' => true, 'unary_operator_spaces' => true, 'phpdoc_single_line_var_spacing' => true, 'phpdoc_var_without_name' => true, 'class_attributes_separation' => ['elements' => ['method' => 'one']], 'method_argument_space' => [ 'on_multiline' => 'ensure_fully_multiline', 'keep_multiple_spaces_after_comma' => true, ], 'single_trait_insert_per_statement' => true, ]) ->setFinder($finder); ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [Unreleased](https://github.com/clickfwd/yoyo/compare/0.15.0...develop) ## [0.15.0 (2026-04-10)](https://github.com/clickfwd/yoyo/compare/0.14.0...0.15.0) ### Added - `Yoyo.dispatch()` and `Yoyo.dispatchTo()` JS API for triggering component events from JavaScript, matching Livewire 3's dispatch API. Thanks to @mucan54 for the original proposal in #35. - Named parameter support for JS dispatch — object params like `{ postId: 2 }` map to listener method arguments by name. - Required parameter validation for named dispatch params — throws `InvalidArgumentException` if a required parameter is missing. - Documentation for all HTMX response header methods available via `$this->response` (`retarget`, `reswap`, `reselect`, `pushUrl`, `replaceUrl`, `location`, `redirect`, `refresh`, `trigger`, `triggerAfterSwap`, `triggerAfterSettle`). ### Fixed - Fix `test_json` empty-params decode bug when `eventParams` is an empty JSON object (`'{}'`). - Add defensive null-guard in `triggerServerEmittedEvent` to prevent TypeError when source element is not inside a component. ### Performance - Add static cache to `getMethodParametersWithTypes()` for consistency with other `ClassHelpers` caches. - Optimize props filtering in `YoyoCompiler` by replacing repeated `in_array` checks with a lookup map. - Skip re-processing already compiled nested Yoyo child components (`yoyo:name` + `hx-vals`) during compile. Benchmark update (5-run averages from `tests/Benchmark/RealWorldBenchmarkTest.php`): - Listing List (10x3 children): `0.5064 -> 0.4488 ms/op` (`-11.4%`) - Listing List (25x3 children): `1.1379 -> 1.0104 ms/op` (`-11.2%`) - Listing List (50x3 children): `2.1507 -> 1.9204 ms/op` (`-10.7%`) Other scenarios remained in the same range with normal benchmark variance. ## [0.14.0 (2025-10-27)](https://github.com/clickfwd/yoyo/compare/0.13.1...0.14.0) ### Breaking Changes - Minimum PHP version is now 8.0+ - `illuminate/container` is now an optional dependency ### Added - Built-in dependency injection container for standalone usage ### Changed - Yoyo now automatically detects and uses `illuminate/container` if available, otherwise uses built-in container ### Migration Notes If you're using Yoyo standalone and need advanced container features, install illuminate/container: ```bash composer require illuminate/container ``` ## [0.13.1 (2025-08-15)](https://github.com/clickfwd/yoyo/compare/0.13.0...0.13.1) - Fix parameter validation to correctly handle optional parameters in component actions ## [0.13.0 (2025-08-15)](https://github.com/clickfwd/yoyo/compare/0.12.0...0.13.0) - Fix spinners stop working when target is different than current element - Add support for variadic parameters in component actions using PHP's `...$params` syntax - Add automatic dependency injection for typed parameters in component actions - Add new ClassHelpers methods: `methodHasVariadicParameter()` and `getMethodParametersWithTypes()` - Enhanced ComponentManager to handle methods with variadic parameters - Improved parameter validation to support methods with only typed (DI) parameters - Container integration now properly handles mixed regular, typed, and variadic parameters ## [0.12.0 (2025-07-18)](https://github.com/clickfwd/yoyo/compare/0.11.1...0.12.0) - Add compatibility up to illuminate/container v12 - Fix deprecated error for implicit nullable parameter value ## [0.11.1 (2025-05-28)](https://github.com/clickfwd/yoyo/compare/0.11.0...0.11.1) - Improve parsing of yoyo attribute action arguments and fix error where they are incorreclty converted to null. ## [0.11.0 (2025-02-06)](https://github.com/clickfwd/yoyo/compare/0.10.0...0.11.0) - Passing attributes to Yoyo\yoyo_render should only prefix HTMX attributes defined in `YoyoCompiler::YOYO_ATTRIBUTES`. ## [0.10.1 (2024-08-29)](https://github.com/clickfwd/yoyo/compare/0.10.0...0.10.1) - Change default hx-include to `this` to improve event-to-request delay on forms with large number of elements. ## [0.10.0 (2024-06-20)](https://github.com/clickfwd/yoyo/compare/0.9.1...0.10.0) - Merge PR to add Falcon framework implementation. ## [0.9.1 (2024-04-16)](https://github.com/clickfwd/yoyo/compare/0.9.0...0.9.1) - Fix Safari/iOS errors due to evt.target and evt.srcElement now being null. - Add support for port in UrlStateManagerService.php - PHP 8.2/8.3 compat - Fix ResponseHeaders::refresh error due to missing parameter. - Fix headers already sent error when setting status code in response - Ensure components are compiled only once. - Bump htmx to v1.9.4 and include new config options. - New Request::set, Request::triggerName and Request::header methods. - New Response::reselect method for the HX-Reselect header. - New New Yoyo::actionArgs method. ## [0.9.0 (2023-04-02)](https://github.com/clickfwd/yoyo/compare/0.8.1...0.9.0) ## New - Added Component::actionMatches method. - Add response HX header methods that can via accessed in Yoyo component via $this->response: - location - pushUrl - redirect - refresh - replace - reswap - retarget - trigger - triggerAfterSwap - triggerAfterSettle ## Changed - Added composer support for illuminate/container v9.0 ## Fixed - Regex replacement in Yoyo compiler causing issues due to incorrect replacements. ## [0.8.2 (2021-07-07)](https://github.com/clickfwd/yoyo/compare/0.8.1...0.8.2) ### Changed - Updated htmx to v1.8.4. - Add support for new htmx attributes to the Yoyo compiler: `replace-url`, `select-oob`, `validate`. - Add support for PHP 8.1 installs. - Add `yoyo:history="remove"` attribute to allow excluding elements from browser history cache snapshot. - Renamed `Component::addDynamicProperties` to `Component::getDynamicProperties` to make it consistent with other component methods. - Expose all htmx configuration options to Yoyo via `Clickfwd\Yoyo\Services\Configuration`. - Breaking change! Compiler no longer makes buttons, links or inputs reactive by default. Previously any button, link or input would automatically receive the yoyo:get="render" attribute unless it already had a different request attribute. Now it's necessary to explicitly add the yoyo:{method} attribute if you want to make the element reactive. You can also just add an empty `yoyo` attribute if you want to make a request to the default yoyo:get="render" attribute on the component. ### Fixed - Allow queryString parameters with value of zero to be pushed to URL. - Javascript `Yoyo.on` throws undefined error when event detail is of type object. - Issues working with dynamic properties. - Lots of other changes and improvements. ## [0.8.1 (2021-07-07)](https://github.com/clickfwd/yoyo/compare/0.8.0...0.8.1) ### Added - Support for adding dynamic properties to components via Component::addDynamicProperties method, which returns an array of property names. NOTE: `renamed to Component::getDynamicProperties` in next update. This can be useful when the names of the properties are not known in advanced (i.e. coming from the database). The code below shows how to use this together with queryStrings to push the dynamic property values to the URL. The dynamic properties are also available in templates like regular public properties. ```php public function addDynamicProperties() { return ['width', 'length']; } public function getQueryString() { return array_merge($this->queryString, $this->addDynamicProperties()); } ``` ## [0.8.0 (2021-07-07)](https://github.com/clickfwd/yoyo/compare/0.7.5...0.8.0) ### Changed - Links that trigger Yoyo requests now automatically update the browser URL and push the component state to the browser history. ## [0.7.5 (2021-05-28)](https://github.com/clickfwd/yoyo/compare/0.7.4...0.7.5) ### Fixed - Error retrieving parameter names for component action. ## [0.7.4 (2021-05-20)](https://github.com/clickfwd/yoyo/compare/0.7.3...0.7.4) ### Fixed - Various fixes ## [0.7.3 (2021-04-04)](https://github.com/clickfwd/yoyo/compare/0.7.2...0.7.3) ### Fixed - Allow component listeners to trigger the default `refresh` action. ```php protected $listeners = ['updated' => 'refresh']; ``` ## [0.7.2 (2021-03-22)](https://github.com/clickfwd/yoyo/compare/0.7.1...0.7.2) ### Fixed - Initial component history snapshot taken even for components that don't push changes to the URL via `queryString`. ## [0.7.1 (2021-03-14)](https://github.com/clickfwd/yoyo/compare/0.7.0...0.7.1) ### Added - Updated htmx to v1.3.1 - Component `emitToWithSelector` method to differentiate from `emitTo`. `emitTo` targets Yoyo components specifically, while `emitToWithSelector` can target elements using a CSS selector. - Component `skipRenderAndRemove` method to allow removing components from the page. - Component `addSwapModifiers` method to dynamically set [swap modifers](https://htmx.org/attributes/hx-swap/) when updating components. - Additional component lifecycle hooks - initialize - on component initialization, allows adding properties, setting listeners, etc. - mount - after component initialization - rendering - before component render method - rendered - after component render method, receives component output - Allow traits to implement lifecycle hooks which run after the component's equivalent. For example, a `trait WithValidation` in addition to adding its own properties and methods can also implement hooks with the format: - `initializeWithValidation` - `mountWithValidation` - `renderingWithValidation` - `renderedWithValidation` - Depedency Injection for lifecycle hooks and listener methods. - Yoyo\abort, Yoyo\abort_if, Yoyo\abort_unless functions allows throwing exceptions within components to stop execution while still sending any queued events back up to the browser. - Namespace support for view templates and component classes - Support for new htmx `hx-headers` attribute via `yoyo:headers` - Tests for Blade and Twig ### Changed - Automatically re-spawn dynamically created target elements if these are removed on swap. When `yoyo:target="#some-element"` is used with an ID and the target element doesn't exist, Yoyo automatically creates an empty `
` and appends it to the document body. - Refactored component resolver - Events are sent to the browser even when throwing an exception within a component. - Components are resolved from the container. ### Fixed - Cannot use Array property as prop. - Component props not persisted in POST request updates. - Variables passed directly to `render` method leaking to component props. ================================================ FILE: LICENSE.md ================================================ ## MIT License Copyright © ClickFWD Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Yoyo Yoyo is a full-stack PHP framework that you can use on any project to create rich dynamic interfaces using server-rendered HTML. With Yoyo, you create reactive components that are seamlessly updated without the need to write any Javascript code. Yoyo ships with a simple templating system, and offers out-of-the-box support for [Blade](https://laravel.com/docs/8.x/blade), without having to use Laravel, and [Twig](https://twig.symfony.com/). Inspired by [Laravel Livewire](https://laravel-livewire.com/) and [Sprig](https://putyourlightson.com/plugins/sprig), and using [htmx](https://htmx.org/). ## 🚀 Yoyo Demo Apps Check out the [Yoyo Demo App](https://app.getyoyo.dev) to get a better idea of what you can build with Yoyo. It showcases many different types of Yoyo components. You can also clone and install the demo apps: - [Yoyo Blade App](https://github.com/clickfwd/yoyo-blade-app) - [Yoyo Laravel App](https://github.com/clickfwd/yoyo-laravel-app) - [Yoyo PHP template App](https://github.com/clickfwd/yoyo-app) - [Yoyo Twig App](https://github.com/clickfwd/yoyo-twig-app) ## Documentation - [How it Works](#how-it-works) - [Installation](#installation) - [Updating](#updating) - [Configuring Yoyo](#configuring-yoyo) - [Creating Components](#creating-components) - [Rendering Components](#rendering-components) - [Properties](#properties) - [Actions](#actions) - [View Data](#view-data) - [Computed Properties](#computed-properties) - [Events](#events) - [Redirecting](#redirecting) - [Component Props](#component-props) - [Query String](#query-string) - [Loading States](#loading-states) - [Using Blade](#using-blade) - [Using Twig](#using-twig) - [License](#license) ## How it Works Yoyo components are rendered on page load and can be individually updated, without the need for page-reloads, based on user interaction and specific events. Component update requests are sent directly to a Yoyo-designated route, where it processes the request and then sends the updated component HTML partial back to the browser. Yoyo can update the browser URL state and trigger browser events straight from the server. Below you can see what a Counter component looks like: **Component class** ```php # /app/Yoyo/Counter.php count++; } } ``` **Component template** ```html
``` Yes, it's that simple! One thing to note above is the use of the protected property `$props`. This indicates to Yoyo that the `count` variable, which is not explicitly available within the template, should be persisted and updated in every request. ## Installation ### Install the Package ```bash composer require clickfwd/yoyo ``` #### Phalcon Framework Installation For phalcon, you need to add di ```php $di->register(new \Clickfwd\Yoyo\YoyoPhalconServiceProvider()); ``` and you need to add router: ```php $router->add('/yoyo', [ 'controller' => 'yoyo', 'action' => 'handle', ]); ``` and you should create a controller and inherit from `Clickfwd\Yoyo\PhalconController` class. ## Updating After performing the usual `composer update`, remember to also update the `yoyo.js` script per the [Load Assets](#load-assets) instructions. ## Configuring Yoyo It's necessary to bootstrap Yoyo with a few configuration settings. This code should run when rendering and updating components. ```php use Clickfwd\Yoyo\View; use Clickfwd\Yoyo\ViewProviders\YoyoViewProvider; use Clickfwd\Yoyo\Yoyo; $yoyo = new Yoyo(); $yoyo->configure([ 'url' => '/yoyo', 'scriptsPath' => 'app/resources/assets/js/', 'namespace' => 'App\\Yoyo\\' ]); // Register the native Yoyo view provider // Pass the Yoyo components' template directory path in the constructor $yoyo->registerViewProvider(function() { return new YoyoViewProvider(new View(__DIR__.'/resources/views/yoyo')); }); ``` **'url'** Absolute or relative URL that will be used to request component updates. **'scriptsPath'** The location where you copied the `yoyo.js` script. **'namespace'** This is the PHP class namespace that will be used to discover auto-loaded dynamic components (components that use a PHP class). If the namespace is not provided or components are in different namespaces, you need to register them manually: ```php $yoyo->registerComponents([ 'counter' => App\Yoyo\Counter::class, ]; ``` You are required to load the component classes at run time, either using a `require` statement to load the component's PHP class file, or by including your component namespaces in you project's `composer.json`. Anonymous components don't need to be registered, but the template name needs to match the component name. ### Dependency Injection Container Yoyo includes a built-in container and automatically detects Laravel's `illuminate/container` if installed. **Simple dependency injection:** ```php class UserProfile extends Component { public function mount(UserRepository $users, $userId) { $this->user = $users->find($userId); } } ``` **For advanced container features**, install `illuminate/container` (automatically detected): ```bash composer require illuminate/container ``` **Using a custom PSR-11 container:** ```php use Clickfwd\Yoyo\ContainerResolver; ContainerResolver::setPreferred($myContainer); ``` ### Load Assets Find `yoyo.js` in the following vendor path and copy it to your project's public assets directory. ```file /vendor/clickfwd/yoyo/src/assets/js/yoyo.js ``` To load the necessary scripts in your template add the following code inside the `` tag: ```php ``` ## Creating Components Dynamic components require a class and a template. When using the Blade and Twig view providers, you can also use inline views, where the component markup is returned directly in the component's `render` method. Anonymous components allow creating components with just a template file. To create a simple search component that retrieves results from the server and updates itself, create the component template: ```html // resources/views/yoyo/search.php
``` Yoyo will render the component output and compile it to add the necessary attributes that makes it dynamic and reactive. When you submit the form, posted data is automatically made available within the component template. The template code can be expanded to show a list of results, or an empty state: ```php ``` ```html
``` The `$results` array can be populated from any source (i.e. database, API, etc.) The example can be converted into a live search input, with a 300ms debounce to minimize the number of requests. Replace the `form` tag with: ```html ``` The `yoyo:on="keyup delay:300ms change"` directive tells Yoyo to make a request on the keyup event, with a 300ms debounce, and only if the input text changed. Now let's turn this into a dynamic component using a class. ```php # /app/Yoyo/Search query; // Perform your database query $entries = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; $results = array_filter($entries, function($entry) use ($query) { return $query && stripos($entry, $query) !== false; }); // Render the component view return $this->view('search',['results' => $results]); } } ``` And the template: ```html ``` A couple of things to note here that are covered in more detail in other sections. 1. The component class includes a `queryString` property that tells Yoyo to automatically include the queryString values in the browser URL after a component update. If you re-load the page with the `query` value in the URL, you'll automatically see the search results on the page. 2. Yoyo will automatically make available component class public properties as template variables. This allows using `$this->query` to access the search keyword in the component and `$query` in the template. When you compare this search example to the counter example at the beginning, you can see that there are no action methods (i.e. increment, decrement). A component update will always default to the `render` method, unless an action is specified via one of the method attributes (i.e. yoyo:get, yoyo:post, etc.). In that case, the action method always runs before the render method. ## Rendering Components There are two instances when components are rendered. On page load, and on component updates. ### Rendering on Page Load To render any component on page load within your templates, use the `yoyo_render` function and pass the component name as the first parameter. ```php ``` For dynamic components, the component name is a hyphenated version of the class name (i.e. LiveSearch → live-search). If you register components while bootstrapping Yoyo using the `registerComponents` method, then you can use the registered alias as the component name. ```php $yoyo->registerComponent('search', App\Yoyo\LiveSearch::class); ``` For anonymous components, the component name should match the template name without the file extension. So if the template name is `form.php`, the component can be rendered with: ```php ``` ### Rendering on updates Use the `yoyo_update` function to automatically process the component request and output the updated component. ```php ``` You need to add this function call for requests routed to the Yoyo `url` used in the initial configuration. ## Properties In dynamic components, all public properties in the component class are automatically made available to the view and tracked in component updates. ```php class HelloWorld extends Component { public $message = 'Hello World!'; } ``` ```html

``` Public properties should only be of type: `string`, `int`, `array`, `boolean`, and should not contain any sensitive information because they can be used in component requests to keep the data in sync. ### Initializing Properties You can initialize properties using the `mount` method of your component which runs right after the component is instantiated, and before the `render` method. ```php class HelloWorld extends Component { public $message; public function mount() { $this->message = 'Hello World!'; } } ``` ### Data Binding You can automatically bind, or synchronize, the value of an HTML element with a component public property. ```php class HelloWorld extends Component { public $message = 'Hello World!'; } ``` ```html

``` Adding the `yoyo` attribute to any input will instantly make it reactive. Any changes to the input will be updated in the component. By the default, the natural event of an element will be used as the event trigger. - input, textarea and select elements are triggered on the change event. - form elements are triggered on the submit event. - All other elements are triggered on the click event. You can modify this behavior using the `yoyo:on` directive which accepts multiple events separated by comma: ```html ``` ### Debouncing and Throttling Requests The are several ways to limit the requests to update components. **`delay`** - debounces the request so it's made only after the specified period passes after the last trigger. ```html ``` **`throttle`** limits request to one dwithin the specified interval. ```html ``` **`changed`** - only makes the request when the input value has changed. ```html ``` ## Actions An action is a request made to a Yoyo component method to update (re-render) it as a result of a user interaction or page event (click, mouseover, scroll, load, etc.). The `render` method is the default action when one is not provided explicitly. You can also override it in the component class to change the template name or when you need to send additional variables to the template in addition to the public properties. ```php public function render() { return $this->view($this->componentName, ['foo' => 'bar']); } ``` To specify an action you use one of the available action directives with the name of the action as the value. - `yoyo:get` - `yoyo:post` - `yoyo:put` - `yoyo:patch` - `yoyo:delete` For example: ```php class Review extends Component { public Review $review; public function helpful() { $this->review->userFoundHelpful($userId); } } ``` ```html
``` All components automatically listen for the `refresh` event and trigger the `render` action to refresh the component state. ### Passing Data to Actions You can include additional data to send to the server on component update requests using the `yoyo:vals` directive which accepts a JSON encoded list of name-value pairs. ```html ``` You can also use `yoyo:val.name` for individual values. kebab-case variable names are automatically converted to camel-case. ```html ``` Yoyo will automatically track and send component public properties and input values with every request. ```php class Review extends Component { public $reviewId; public function helpful() { // access reviewId via $this->reviewId } } ``` You can also pass extra parameters to an action as arguments using an expression, without having to define them as public properties in the component: ```html ``` Extra parameters passed to an action are made available to the component method as regular arguments: ```php public function addToCart($productId, $style) { // ... } ``` ### Actions Without a Response Sometimes you may want to use a component action only to make changes to a database and trigger events, without rendering a response. You can use the component `skipRender` method for this: ```php public function savePost() { // Store the post to the database // Send event to the browser to close modal, or trigger a notification $this->emitSelf('PostSaved'); // Skip template rendering $this->skipRender(); } ``` ## View Data Sometimes you want to send data to a view without declaring the variable as a public property. You can do this by defining a render method in your component and passing a data array as the second argument: ```php public function render() { return $this->view($this->componentName, ['foo' => 'bar']); } ``` Then access the $foo variable in your template. You can also send data to the component view using the `set` method in any component action. For example: ```php public function increment() { $this->set('foo', 'bar'); // or $this->set(['foo' => 'bar']); } ``` ## Computed Properties ```php class HelloWorld extends Component { public $message = 'Hello World!'; // Computed Property public function getHelloWorldProperty() { return $message; } // Computed Property with argument public function getErrorsProperty($name) { return [ 'title' => 'Please enter a title', 'description' => 'Please enter a description', ][$name] ?? null; } } ``` Now, you can access `$this->hello_world` from either the component's class or template: ```php

hello_world ;?>

``` Computed properties with arguments behave like normal class methods that you can call in your templates: ```php

errors('title') ;?>

``` The output of computed properties is cached within the same component request, allowing you to perform complex tasks like querying the database and not duplicating the tasks if the property is accessed multiple times. If you need to clear the cache for a computed property: ```php // Clear all computed properties, including those with arguments $this->forgetComputed(); // Clear a single property $this->forgetComputed($property); // Clear multiple properties $this->forgetComputed([$property1, $property2]); // Clear a single computed property with arguments $this->forgetComputedWithArgs($property, $arg1, $arg2); ``` ## Component Props Yoyo can persist and update variables in requests without the need to explicitly include an input element. For an anonymous component, it's possible to specify the props directly in the component root node using a comma-separated list of variable names and this allows implementing a counter without the need for a component class: ```php

``` By adding the `yoyo:props="count"`, Yoyo knows to automatically include the value of `count` in every request. For dynamic components, there's no need to use the `yoyo:props` attribute because we use the protected method $props in the component class with an array of variable names. ```php class Counter extends Component { public $count = 0; protected $props = ['count']; public function increment() { $this->count++; } } ``` Since the `$count` variable is also defined as a public property, it's already available in the template and the value is incremented throgh the `increment` method in the component class without having to use `yoyo:val.count`. ```html
``` ## Query String Components have the ability to automatically update the browser's query string on state changes. ```php class Search extends Component { public $query; protected $queryString = ['query']; } ``` Yoyo is smart enough to automatically remove the query string when the current state value matches the property's default value. For example, in a pagination component, you don't need the `?page=1` query string to appear in the URL. ```php class Posts extends Component { public $page = 1; protected $queryString = ['page']; } ``` ## Loading States Updating Yoyo components requires an Ajax request to the server and depending on what the component does, the response time will vary. The `yoyo:spinning` directive allows you to do all sorts of cool things when a component is updating to provide a visual indicator to end-users. ### Toggling Elements During Loading States To show an element at the start of a Yoyo update request and hide it again when the update is complete: ```html
Processing your submission...
``` Yoyo adds some CSS to the page to automatically hide the element with the `yoyo:spinning` directive. To hide a visible element while the component is updating you can add the `remove` modifier: ```html
Text hidden while updating ...
``` ## Delaying Loading States Some actions may update quickly and showing a loading state in these cases may be more of a distraction. The `delay` modifier ensures that the loading state changes are applied only after 200ms if the component hasn't finished updating. ```html
Processing your submission...
``` ### Targeting Specific Actions If you need to toggle different indicators for different component actions, you can add the `yoyo:spin-on` directive and pass a comma separated list of action names. For example: ```html
Show for edit action
Show for like action
Show for edit and like actions
``` ## Toggling Element CSS Classes Instead of toggling the visibility of an element you can also add specific CSS classes while the component updates. Use the `class` modifier and include the space-separated class names as the attribute value: ```html
``` You can also remove specific class names by adding the `remove` modifier: ```html
``` ## Toggling Element Attributes Similar to CSS class toggling, you can also add or remove attributes while the component is updating. ```html
``` ## Events Events are a great way to establish communication between Yoyo components on the same page, where one or more components can listen to events fired by another component. Events can be fired from component methods and templates using a variety of emit methods. All emit methods accept any number of arguments that allow sending data (string, number, array) to listeners. ### Emitting an Event to All Yoyo Components From a component method. ```php public function increment() { $this->count++; $this->emit('counter-updated', $count); } ``` From a template ```php emit('counter-updated', $count) ; ?> ``` ### Emitting an Event to Parent Components When dealing with nested components you can emit events to parents and not children or sibling components. ```php $this->emitUp('postWascreated', $arg1, $arg2); ``` ### Emitting an Event to a Specific Component When you need to emit an event to a specific component using the component name (e.g. `cart`). ```php $this->emitTo('cart', 'productAddedToCart', $arg1, $arg2); ``` ### Emitting an Event to an Element Using a Selector The `emitTo` method also works with selectors. When a component is not found, the selector is used instead. Emitting events using selectors doesn't support passing arguments. ```php $this->emitTo('.cart', 'productAddedToCart'); $this->emitTo('#cart', 'productAddedToCart'); $this->emitTo('.post-100', 'saved'); ``` #### Emitting an Event to Itself When you need to emit an event on the same component. ```php $this->emitSelf('productAddedToCart', $arg1, $arg2); ``` ### Listening for Events To register listeners in Yoyo, use the `$listeners` protected property of the component. Listeners are a key->value pair where the key is the event to listen for, and the value is the method to call on the component. If the event and method are the same, you can leave out the key. ```php class Counter extends Component { public $message; protected $listeners = ['counter-updated' => 'showNewCount']; protected function showNewCount($count) { $this->message = "The new count is: $count"; } } ``` ### Listening For Events In JavaScript Yoyo allows registering event listeners for component emitted events: ```js ``` With this feature you can control toasters, alerts, modals, etc. directly from a component action on the server by emitting the event and listening for it on the browser. ### Dispatching Events From JavaScript You can also trigger component events directly from your custom JavaScript using the `Yoyo.dispatch()` method. This is useful when you need to interact with other JavaScript libraries or custom browser APIs. ```js // Dispatch an event to all Yoyo components listening for it Yoyo.dispatch('post-created'); // You can also pass named parameters to the event listener Yoyo.dispatch('post-created', { postId: 2 }); // Dispatch an event to a specific component by name Yoyo.dispatchTo('dashboard', 'post-created', { postId: 2 }); ``` Parameters are passed as named arguments that match the listener method's parameter names: ```php protected $listeners = [ 'post-created' => 'handlePostCreated', ]; public function handlePostCreated($postId) { // $postId will be 2 } ``` ### Dispatching Browser Events In addition to allowing components to communicate with each other, you can also send browser window events directly from a component method or template: ```php // passing single value $this->dispatchBrowserEvent('counter-updated', $count); // Passing an array $this->dispatchBrowserEvent('counter-updated', ['count' => $count]); ``` And listen for the event anywhere on the page: ```js ``` ## Redirecting Sometimes you may want to redirect the user to a different page after performing an action within a Yoyo component. ```php class Registration extends Component { public function register() { // Create the user $this->redirect('/welcome'); } } ``` ## Response Headers Yoyo components have access to `$this->response` which provides methods for controlling how HTMX handles the response. These map directly to [HTMX response headers](https://htmx.org/reference/#response_headers). ### Retargeting Override which element receives the swap: ```php public function save() { // Swap the response into a different element instead of the component itself $this->response->retarget('#notification-area'); } ``` ### Changing Swap Strategy Override the swap strategy for the response: ```php public function update() { // Use innerHTML instead of the default outerHTML swap $this->response->reswap('innerHTML'); } ``` ### Selecting Response Content Select a subset of the response HTML to swap: ```php public function load() { // Only swap the #content portion of the response $this->response->reselect('#content'); } ``` ### URL Management Push or replace the browser URL without a full page reload: ```php public function navigate() { // Push a new URL to browser history $this->response->pushUrl('/new-page'); } public function filter() { // Replace the current URL without adding a history entry $this->response->replaceUrl('/results?q=search'); } ``` ### Client-Side Navigation Perform a client-side redirect (AJAX-style, no full reload) or a full redirect: ```php public function softRedirect() { // AJAX navigation — loads content without a full page reload $this->response->location('/dashboard'); } public function fullRedirect() { // Full page redirect via HX-Redirect header $this->response->redirect('/login'); } ``` > **Note:** `$this->response->redirect()` sets the `HX-Redirect` header (HTMX native redirect). This is different from `$this->redirect()` which uses Yoyo's own `Yoyo-Redirect` header. Both achieve a full page redirect but through different mechanisms. ### Triggering Client-Side Events Trigger browser events from the server that JavaScript can listen for: ```php public function save() { // Trigger immediately after the response is received $this->response->trigger('item-saved'); // Trigger after the swap is complete $this->response->triggerAfterSwap('swap-complete'); // Trigger after the settle phase (CSS transitions finished) $this->response->triggerAfterSettle('settle-complete'); } ``` Listen for these events in JavaScript: ```js document.body.addEventListener('item-saved', function() { // Show a toast notification, update a counter, etc. }); ``` ### Full Page Refresh Force a full page refresh from a component action: ```php public function reset() { $this->response->refresh(); } ``` ## Using Blade You can use Yoyo with Laravel's [Blade](https://laravel.com/docs/8.x/blade) templating engine, without having to use Laravel. ### Installation To get started install the following packages in your project: ```bash composer require clickfwd/yoyo composer require jenssegers/blade ``` ### Configuration Create a Blade instance and set it as the view provider for Yoyo. We also add the `YoyoServiceProvider` for Blade. This code should run when rendering and updating components. ```php configure([ 'url' => 'yoyo', 'scriptsPath' => APP_PATH.'/app/resources/assets/js/', 'namespace' => 'App\\Yoyo\\', ]); // Create a Blade instance $app = Application::getInstance(); $app->bind(ApplicationContract::class, Application::class); // Needed for Blade anonymous components $app->alias('view', ViewFactory::class); $app->extend('config', function (array $config) { return new Fluent($config); }); $blade = new Blade( [ APP_PATH.'/resources/views', APP_PATH.'/resources/views/yoyo', APP_PATH.'/resources/views/components', ], APP_PATH.'/../cache', $app ); $app->bind('view', function () use ($blade) { return $blade; }); (new YoyoServiceProvider($app))->boot(); // Optionally register Blade components $blade->compiler()->components([ // 'button' => 'button', ]); // Register Blade view provider for Yoyo $yoyo->registerViewProvider(function() use ($blade) { return new BladeViewProvider($blade); }); ``` ### Load Assets Find `yoyo.js` in the following vendor path and copy it to your project's public assets directory. ```file /vendor/clickfwd/yoyo/src/assets/js/yoyo.js ``` To load the necessary scripts in your Blade template you can use the `yoyo_scripts` directive in the `` tag: ```blade @yoyo_scripts ``` ### Rendering a Blade View You can use the Blade instance to render any Blade view. ``` $blade = \Clickfwd\Yoyo\Yoyo::getViewProvider()->getProviderInstance(); echo $blade->render('home'); ``` ### Rendering Yoyo Blade Components To render Yoyo components inside Blade views, use the `@yoyo` directive. ```blade @yoyo('search') ``` ### Updating Yoyo Blade Components To update Yoyo components in the Yoyo-designated route. ```php echo (new \Clickfwd\Yoyo\Blade\Yoyo())->update(); ``` ### Inline Views When dealing with simple templates, you can create components without a template file and instead return an inline view in the component's `render` method. ```php class HelloWorld extends Component { public $message = 'Hello World!'; } public function render() { return <<<'yoyo'

{{ $message }}

yoyo; } ``` ### Other Blade Features Yoyo implements several Blade directives that can be used within Yoyo component templates. - `@spinning` and `@endspinning` - Check if a component is being re-rendered. ```blade @spinnning Component updated @endspinning @spinning($liked == 1) Component updated and liked == 1 @endspinning ``` - All event methods are available as directives within blade components ```blade @emit('eventName', ['foo' => 'bar']); @emitUp('eventName', ['foo' => 'bar']); @emitSelf('eventName', ['foo' => 'bar']); @emitTo('component-name', 'eventName', ['foo' => 'bar']); ``` - Computed properties ```php class HelloWorld extends Component { public $message = 'Hello World!'; public function getHelloWorldProperty() { return $message; } } ``` ```blade

{{ $this->hello_world }}

``` ## Using Twig You can use Yoyo with Symfony's [Twig](https://twig.symfony.com/) templating engine. ### Installation To get started install the following packages in your project: ```bash composer require clickfwd/yoyo composer require twig/twig ``` ### Configuration Create a Twig instance and set it as the view provider for Yoyo. We also add the `YoyoTwigExtension` to Twig. This code should run when rendering and updating components. ```php use Clickfwd\Yoyo\Twig\YoyoTwigExtension; use Clickfwd\Yoyo\ViewProviders\TwigViewProvider; use Clickfwd\Yoyo\Yoyo; use Twig\Extension\DebugExtension; define('APP_PATH', __DIR__); $yoyo = new Yoyo(); $yoyo->configure([ 'url' => 'yoyo', 'scriptsPath' => APP_PATH.'/app/resources/assets/js/', 'namespace' => 'App\\Yoyo\\', ]); $loader = new \Twig\Loader\FilesystemLoader([ APP_PATH.'/resources/views', APP_PATH.'/resources/views/yoyo', ]); $twig = new \Twig\Environment($loader, [ 'cache' => APP_PATH.'/../cache', 'auto_reload' => true, // 'debug' => true ]); // Add Yoyo's Twig Extension $twig->addExtension(new YoyoTwigExtension()); // Register Twig view provider for Yoyo $yoyo->registerViewProvider(function() use ($twig) { return new TwigViewProvider($twig); }); ``` ### Load Assets Find `yoyo.js` in the following vendor path and copy it to your project's public assets directory. ```file /vendor/clickfwd/yoyo/src/assets/js/yoyo.js ``` To load the necessary scripts in your Twig template you can use the `yoyo_scripts` function in the `` tag: ```twig {{ yoyo_scripts() }} ``` ### Rendering a Twig View You can use the Twig instance to render any Twig view. ``` $twig = \Clickfwd\Yoyo\Yoyo::getViewProvider()->getProviderInstance(); echo $twig->render('home'); ``` ### Rendering Yoyo Twig Components To render Yoyo components inside Twig views, use the `yoyo` function. ```twig yoyo('search') ``` ### Updating Yoyo Twig Components To update Yoyo components in the Yoyo-designated route. ```php echo (new \Clickfwd\Yoyo\Yoyo())->update(); ``` ### Inline Views When dealing with simple templates, you can create components without a template file and instead return an inline view in the component's `render` method. ```php class HelloWorld extends Component { public $message = 'Hello World!'; } public function render() { return <<<'twig'

{{ message }}

twig; } ``` ### Other Twig Features Yoyo adds a few functions and variables that can be used within Yoyo component templates. - The `spinning` variable can be used to check if a component is being re-rendered. ```twig {% if spinning %} Component updated {% endif %} ``` - All event methods are available as functions within blade components ```twig {{ emit('eventName', {'foo':'bar'}) }} {{ emitUp('eventName', {'foo':'bar'}) }} {{ emitSelf('eventName', {'foo':'bar'}) }} {{ emitTo('component-name', 'eventName', {'foo':'bar'}) }} ``` - Computed properties ```php class HelloWorld extends Component { public $message = 'Hello World!'; public function getHelloWorldProperty() { return $this->message; } } ``` ```twig

{{ this.hello_world }}

``` ## License Copyright © ClickFWD Yoyo is open-sourced software licensed under the [MIT license](LICENSE.md). ================================================ FILE: composer.json ================================================ { "name": "clickfwd/yoyo", "description": "Framework to build dynamic interfaces with seamless communication between frontend and backend.", "keywords": [ "framework", "yoyo" ], "license": "MIT", "homepage": "https://github.com/Clickfwd/yoyo", "support": { "issues": "https://github.com/Clickfwd/yoyo/issues", "source": "https://github.com/Clickfwd/yoyo" }, "authors": [ { "name": "Clickfwd" } ], "minimum-stability": "dev", "prefer-stable": true, "require": { "php": "^8.0", "psr/container": "^1.1.1|^2.0.1" }, "suggest": { "illuminate/container": "Required for advanced dependency injection or Blade integration" }, "require-dev": { "phpunit/phpunit": "^12.0", "pestphp/pest": "^4.0", "pestphp/pest-plugin-browser": "^4.0", "jenssegers/blade": "^2.0", "symfony/var-dumper": "^5.2|^6.0|^7.0", "spatie/ray": "^1.20", "twig/twig": "^3.4", "illuminate/container": "^8.0||^9.0||^10.0||^11.0||^12.0", "friendsofphp/php-cs-fixer": "^3.39" }, "autoload": { "files": [ "src/yoyo/helpers.php" ], "psr-4": { "Clickfwd\\Yoyo\\": "src/yoyo" } }, "autoload-dev": { "psr-4": { "Tests\\App\\": "tests/app", "Tests\\AppAnother\\": "tests/app-another", "Tests\\Browser\\": "tests/Browser", "Tests\\Browser\\Components\\": "tests/Browser/Components" } }, "config": { "allow-plugins": { "pestphp/pest-plugin": true } } } ================================================ FILE: package.json ================================================ { "name": "yoyo-tests", "private": true, "scripts": { "test:browser": "./vendor/bin/pest --testsuite Browser", "test:browser:headed": "./vendor/bin/pest --testsuite Browser --headed" }, "devDependencies": { "playwright": "^1.54.1" } } ================================================ FILE: phpunit.xml ================================================ ./tests/Unit ./tests/Feature ./tests/Browser ./tests/Benchmark ./src ================================================ FILE: src/assets/js/yoyo.js ================================================ ; (function (global, factory) { if (typeof define === 'function' && define.amd) { define([], factory) } else { global.Yoyo = factory() } })(typeof self !== 'undefined' ? self : this, function () { return (function () { 'use strict' window.YoyoEngine = window.htmx window.addEventListener('popstate', (event) => { event?.state?.yoyo?.forEach((state) => restoreComponentStateFromHistory(state) ) }) var Yoyo = { url: null, config(options) { Object.keys(options).forEach((key) => { YoyoEngine.config[key] = options[key] }) }, on(name, callback) { YoyoEngine.on(window, name, (event) => { delete event.detail.elt callback(event.detail) }) }, dispatch(eventName, params = null) { this.processEmitEvents(document.body, [ { event: eventName, params: params } ]) }, dispatchTo(componentName, eventName, params = null) { if (!/^[a-zA-Z0-9._-]+$/.test(componentName)) return this.processEmitEvents(document.body, [ { event: eventName, params: params, component: componentName } ]) }, createNonExistentIdTarget(targetId) { // Dynamically create non-existent target IDs by appending them to document body if ( targetId && targetId[0] == '#' && document.querySelector(targetId) === null ) { let targetDiv = document.createElement('div') targetDiv.setAttribute('id', targetId.replace('#', '')) document.body.appendChild(targetDiv) } }, afterProcessNode(evt) { // Create non-existent target if (evt.detail.elt) { this.createNonExistentIdTarget( evt.detail.elt.getAttribute('hx-target') ) } // Initialize spinners let component if (!evt.detail.elt || !isComponent(evt.detail.elt)) { // For innerHTML swap find the component root node component = YoyoEngine.closest( evt.detail.elt, '[hx-swap~=innerHTML]' ) } else { component = getComponent(evt.detail.elt) } // Fallback: try to find the nearest component from evt.detail.elt or evt.detail.target if (!component && evt.detail.elt) { component = getComponent(evt.detail.elt); } if (!component && evt.detail.target) { component = getComponent(evt.detail.target); } if (!component) { return; } initializeComponentSpinners(component) }, bootstrapRequest(evt) { const elt = evt.detail.elt let component = getComponent(elt) const componentName = getComponentName(component) if (evt.detail.path === document.location.href) { evt.detail.path = 'render' } // Includes the commonly-used X-Requested-With header that identifies ajax requests in many backend frameworks evt.detail.headers['X-Requested-With'] = 'XMLHttpRequest' const action = getActionAndParseArguments(evt.detail) evt.detail.parameters[ 'component' ] = `${componentName}/${action}` evt.detail.path = Yoyo.url // Make request info available to other events componentAddYoyoData(component, { action }) eventsMiddleware(evt) }, processRedirectHeader(xhr) { if (xhr.getAllResponseHeaders().match(/Yoyo-Redirect:/i)) { const url = xhr.getResponseHeader('Yoyo-Redirect') if (url) { window.location = url } } }, processEmitEvents(elt, events) { if (!events || events == '[]') return events = typeof events == 'string' ? JSON.parse(events) : events yoyoEventCache.clear() events.forEach((event) => { triggerServerEmittedEvent(elt, event) }) }, processBrowserEvents(events) { if (!events) return events = typeof events == 'string' ? JSON.parse(events) : events events.forEach((event) => { window.dispatchEvent( new CustomEvent(event.event, { detail: event.params, }) ) }) }, beforeRequestActions(elt) { let component = getComponent(elt) spinningStart(component) }, afterOnLoadActions(evt) { let component = getComponentById(evt.detail.target.id) if (!component) { if (!evt.detail.elt) { return } // Needed when using yoyo:select to replace a specific part of the response // so stop spinning callbacks are run to remove animations in the parts of the component // that were not replaced component = getComponent(evt.detail.elt) if (component) { spinningStop(component) } return } componentCopyYoyoDataFromTo(evt.detail.target, component) // For 204 No Content responses or empty responses, explicitly stop spinners // since no DOM swap occurs that would naturally clean up spinner states const xhr = evt.detail.xhr if (xhr.status === 204 || !xhr.responseText) { spinningStop(component) } // This isn't needed at this time because the CSS classes/attributes are // automatically removed when a component is updated from the server // however, could be useful to improve transitions in the future. It would // be necessary to add back spinner classes before new HTML is swapped in // spinningStop(component) // Timeout needed for targets outside of Yoyo component setTimeout(() => { removeEventListenerData(component) }, 125) }, afterSettleActions(evt) { /// HISTORY // At this time, browser history support only works with outerHTML swaps const component = getComponentById(evt.detail.elt.id) if (!component) return const xhr = evt.detail.xhr // Browser history automatically enabled for components with queryStrings let history = component.hasAttribute('yoyo:history') let pushedUrl = xhr.getResponseHeader('Yoyo-Push') let triggerId = evt.detail.requestConfig?.triggerEltInfo?.id || evt.detail.requestConfig.headers['HX-Trigger'] let href = triggerId ? evt.detail.requestConfig?.triggerEltInfo?.href : false // If reactive element has an href tag, override history setting and add the component and href to browser history if (triggerId && href) { pushedUrl = href history = true } const url = pushedUrl !== null ? pushedUrl : window.location.href if ( !history || !pushedUrl || component?.__yoyo?.replayingHistory ) { component.__yoyo.replayingHistory = false return } componentAddYoyoData(component, { effects: { browserEvents: xhr.getResponseHeader('Yoyo-Browser-Event'), emitEvents: xhr.getResponseHeader('Yoyo-Emit'), }, }) const componentName = getComponentName(component) YoyoEngine.findAll( evt.detail.target, '[yoyo\\:history=remove]' ).forEach((node) => node.remove()) // Before pushing a component to the browser history, we need to take a snapshot // of its initial rendered-HTML to store it in the current state // This also works for components loaded dynamically onto the page, like modals if (!componentAlreadyInCurrentHistoryState(component)) { updateState( 'replaceState', document.location.href, component, true, evt.detail.target.outerHTML ) } if ( !history?.state?.yoyo || history?.state?.initialState || url !== window.location.href ) { updateState('pushState', url, component) } else { updateState('replaceState', url, component) } }, } /** * Tracking for elements receiving multiple emitted events to only trigger the first one */ let yoyoEventCache = new Set() let yoyoSpinners = {} function getActionAndParseArguments(detail) { const m = detail.path.match(/^(.+?)\((.*)\)$/); if (!m) { // no “(…)” → action is the full path return detail.path; } const [, actionName, rawArgs] = m; const args = rawArgs.trim() === '' ? [] : rawArgs .split(/\s*,\s*/) // split on commas + trim .map(parseArg); detail.parameters.actionArgs = JSON.stringify(args); return actionName; } function parseArg(token) { // quoted string? if (/^['"].*['"]$/.test(token)) { return token.slice(1, -1); } // boolean literals? if (token === 'true') return true; if (token === 'false') return false; // try number const num = Number(token); if (!isNaN(num) && isFinite(num)) { return num; } // fallback to raw string return token; } function isComponent(elt) { return elt?.hasAttribute('yoyo:name') } function getComponent(elt) { let component = elt.closest('[yoyo\\:name]') if (component) { component.__yoyo = component?.__yoyo || {} } return component } function getAllcomponents() { return document.querySelectorAll('[yoyo\\:name]') } function getComponentById(componentId) { if (!componentId) return null const component = document.querySelector(`#${componentId}`) return isComponent(component) ? component : null } function getComponentName(component) { return component.getAttribute('yoyo:name') } function getComponentFingerprint(component) { return `${getComponentName( component )}:${getComponentIndex(component)}` } function getComponentsByName(name) { return Array.from( document.querySelectorAll(`[yoyo\\:name="${name}"]`) ) } // Index as it appears on the page relative to other same-named components function getComponentIndex(component) { const name = getComponentName(component) const components = getComponentsByName(name) return components.indexOf(component) } function getAncestorcomponents(selector) { let ancestor = getComponent(document.querySelector(selector)) let ancestors = [] while (ancestor) { ancestors.push(ancestor) ancestor = getComponent(ancestor.parentElement) } // Remove the current component ancestors.shift() return ancestors } function shouldTriggerYoyoEvent(elt, eventName) { let key if (isComponent(elt)) { key = `${elt.id}${eventName}` } else if (elt.selector !== undefined) { return true } if (key && !yoyoEventCache.has(key)) { yoyoEventCache.add(key) return true } return false } function eventsMiddleware(evt) { const component = getComponent(evt.detail.elt) const componentName = getComponentName(component) const eventData = component.__yoyo.eventListener if (!eventData) return evt.detail.parameters[ 'component' ] = `${componentName}/${eventData.name}` if (eventData.params) { delete eventData.params.elt } evt.detail.parameters = { ...evt.detail.parameters, ...{ eventParams: eventData.params ? JSON.stringify(eventData.params) : [], }, } } function addEmittedEventParametersToListenerComponent( component, event, params ) { // Check if Yoyo component is listening for the event let componentListeningFor = component .getAttribute('hx-trigger') .split(',') .filter((name) => name.trim()) if (componentListeningFor.indexOf(event) === -1) { return } componentAddYoyoData(component, { eventListener: { name: event, params: params }, }) } function triggerServerEmittedEvent(elt, event) { const component = getComponent(elt) const eventName = event.event const params = event.params const selector = event.selector || null const componentName = event.component || null const propagation = event.propagation || null let elements if (!component && (propagation === 'self' || selector)) return // emit if (!selector && !componentName) { elements = getAllcomponents() } else if (componentName) { // emitUp if (propagation == 'ancestorsOnly') { elements = getAncestorcomponents(selector) // emitSelf } else if (propagation == 'self') { elements = [component] // emitTo } else { elements = getComponentsByName(componentName) } // emitWithSelector, excludes current component to allow replication without udpating the current component twice } else if (selector) { elements = document.querySelectorAll(selector) elements = Array.from(elements).filter( (element) => !component.contains(element) ) elements.forEach((elt) => (elt.selector = selector)) } if (elements.length) { elements.forEach((elt) => { if (shouldTriggerYoyoEvent(elt, eventName)) { addEmittedEventParametersToListenerComponent( getComponent(elt), eventName, params ) YoyoEngine.trigger(elt, eventName, params) } }) } } function removeEventListenerData(component) { delete component.__yoyo.eventListener } /** * Component loading state spinners */ function spinningStart(component) { const componentId = component.id if (!yoyoSpinners[componentId]) { return } let spinningElts = yoyoSpinners[componentId].generic || [] spinningElts = spinningElts.concat( yoyoSpinners[componentId]?.actions[component.__yoyo.action] || [] ) delete yoyoSpinners[component.id] spinningElts.forEach((directive) => { const spinnerElt = directive.elt if (directive.modifiers.includes('class')) { let classes = directive.value.split(' ').filter(Boolean) doAndSetCallbackOnElToUndo( component, directive, () => directive.elt.classList.add(...classes), () => spinnerElt.classList.remove(...classes) ) } else if (directive.modifiers.includes('attr')) { doAndSetCallbackOnElToUndo( component, directive, () => directive.elt.setAttribute(directive.value, true), () => spinnerElt.removeAttribute(directive.value) ) } else { doAndSetCallbackOnElToUndo( component, directive, () => (spinnerElt.style.display = 'inline-block'), () => (spinnerElt.style.display = 'none') ) } }) } function spinningStop(component) { while (component.__yoyo_on_finish_loading.length > 0) { component.__yoyo_on_finish_loading.shift()() } } function initializeComponentSpinners(component) { const componentId = component.id component.__yoyo_on_finish_loading = [] walk(component, (elt) => { const directive = extractModifiersAndValue(elt, 'spinning') if (directive) { const yoyoSpinOnAction = elt.getAttribute('yoyo:spin-on') if (yoyoSpinOnAction) { yoyoSpinOnAction .replace(' ', '') .split(',') .forEach((action) => { addActionSpinner(componentId, action, directive) }) } else { addGenericSpinner(componentId, directive) } } }) } function checkSpinnerInitialized(componentId, action) { yoyoSpinners[componentId] = yoyoSpinners[componentId] || { actions: {}, generic: [], } if ( action && yoyoSpinners?.[componentId]?.actions?.[action] === undefined ) { yoyoSpinners[componentId].actions[action] = [] } } function addActionSpinner(componentId, action, directive) { checkSpinnerInitialized(componentId, action) yoyoSpinners[componentId].actions[action].push(directive) } function addGenericSpinner(componentId, directive) { checkSpinnerInitialized(componentId) yoyoSpinners[componentId].generic.push(directive) } // https://github.com/livewire/livewire function doAndSetCallbackOnElToUndo( el, directive, doCallback, undoCallback ) { if (directive.modifiers.includes('remove')) [doCallback, undoCallback] = [undoCallback, doCallback] if (directive.modifiers.includes('delay')) { let timeout = setTimeout(() => { doCallback() el.__yoyo_on_finish_loading.push(() => undoCallback()) }, 200) el.__yoyo_on_finish_loading.push(() => clearTimeout(timeout)) } else { doCallback() el.__yoyo_on_finish_loading.push(() => undoCallback()) } } function componentAlreadyInCurrentHistoryState(component) { if (!history?.state?.yoyo) return false history.state.yoyo.forEach((state) => { if (state.fingerprint == getComponentFingerprint(component)) { return true } }) return false } /** * Component state caching for browser history */ function updateState( method, url, component, initialState, originalHTML ) { const id = component.id const componentName = getComponentName(component) const componentIndex = getComponentIndex(component) const fingerprint = getComponentFingerprint(component) const html = originalHTML ? originalHTML : component.outerHTML const effects = component.__yoyo.effects || {} const newState = { url, id, componentName, componentIndex, fingerprint, html, effects, initialState, } const stateArray = method == 'pushState' ? [newState] : replaceStateByComponentIndex(newState) history[method]( { yoyo: stateArray, initialState: initialState }, '', url ) } function replaceStateByComponentIndex(newState) { let stateArray = history?.state?.yoyo || [] let fingerprintFound = false stateArray.map((state) => { if (state.fingerprint == newState.fingerprint) { fingerprintFound = true return newState } return state }) if (!fingerprintFound) { stateArray.push(newState) } return stateArray } function restoreComponentStateFromHistory(state) { const componentName = state.componentName const componentsWithSameName = getComponentsByName(componentName) let component = componentsWithSameName[state.componentIndex] // If the component cannot be found by index, try a simple ID check // This is needed for components dynamically added to the page, like modals // and it works when the component id is pre-determined (i.e. not randomly generated) if (!component) { component = getComponentById(state.id) if (!component) return } var parser = new DOMParser() var cached = parser.parseFromString(state.html, 'text/html').body .firstElementChild component.replaceWith(cached) htmx.process(cached) // Trigger full server refresh when coming back to the original state // so server-sent events on the render/refresh method are run if (state.initialState) { componentAddYoyoData(cached, { replayingHistory: true }) YoyoEngine.trigger(cached, 'refresh') } else { Yoyo.processBrowserEvents(state?.effects?.browserEvents) Yoyo.processEmitEvents(component, state?.effects?.emitEvents) } } function componentCopyYoyoDataFromTo(from, to) { to.__yoyo = from?.__yoyo || {} to.__yoyo_on_finish_loading = from?.__yoyo_on_finish_loading || [] } function componentAddYoyoData(component, data) { if (!data) return component.__yoyo = Object.assign(component.__yoyo, data) } // https://github.com/alpinejs/alpine/ function walk(el, callback) { if (callback(el) === false) return let node = el.firstElementChild while (node) { walk(node, callback) node = node.nextElementSibling } } function extractModifiersAndValue(elt, type) { const attr = elt .getAttributeNames() // Filter only the Yoyo spinning directives. .filter((name) => name.match(new RegExp(`yoyo:${type}`))) if (attr.length) { const name = attr[0] const [ntype, ...modifiers] = name .replace(new RegExp(`yoyo:${type}`), '') .split('.') const value = elt.getAttribute(name) return { elt, name, value, modifiers } } return false } return Yoyo })() }) YoyoEngine.defineExtension('yoyo', { onEvent: function (name, evt) { if (name === 'htmx:afterProcessNode') { Yoyo.afterProcessNode(evt) } if (name === 'htmx:configRequest') { if (!evt.detail.elt) return Yoyo.bootstrapRequest(evt) } if (name === 'htmx:beforeRequest') { if (!Yoyo.url) { console.error('The yoyo URL needs to be defined') evt.preventDefault() } Yoyo.beforeRequestActions(evt.detail.elt) } if (name === 'htmx:afterOnLoad') { Yoyo.afterOnLoadActions(evt) const xhr = evt.detail.xhr Yoyo.processEmitEvents( evt.detail.elt, xhr.getResponseHeader('Yoyo-Emit') ) Yoyo.processBrowserEvents( xhr.getResponseHeader('Yoyo-Browser-Event') ) Yoyo.processRedirectHeader(xhr) // Re-spawn targets removed from the page and take into account swap delays let modifier = xhr.getResponseHeader('Yoyo-Swap-Modifier') if (!modifier) return let swap = modifier.match(/swap:([0-9.]+)s/) let time = swap[1] ? swap[1] * 1000 + 1 : 0 setTimeout(() => { if ( !evt.detail.target.isConnected && document.querySelector( `[hx-target="#${evt.detail.target.id}"]` ) ) { Yoyo.createNonExistentIdTarget(`#${evt.detail.target.id}`) } }, time) } if (name === 'htmx:beforeSwap') { if (!evt.detail.elt) return // Add triggering element info to event detail so it can be read in after swap events // For example to push the href url to browser history using the href from the element that's no longer present on the page let triggerId = evt.detail.requestConfig.headers['HX-Trigger'] || null let triggeringElt = htmx.find(`#${triggerId}`) if (triggerId && triggeringElt) { evt.detail.requestConfig.triggerEltInfo = { id: triggerId, href: triggeringElt.getAttribute('href'), } } const modifier = evt.detail.xhr.getResponseHeader('Yoyo-Swap-Modifier') if (modifier) { const swap = evt.detail.elt.getAttribute('hx-swap') || YoyoEngine.config.defaultSwapStyle evt.detail.elt.setAttribute('hx-swap', `${swap} ${modifier}`) } Yoyo.processBrowserEvents( evt.detail.xhr.getResponseHeader('Yoyo-Browser-Event') ) } if (name === 'htmx:afterSettle') { // Push component response to history cache // Make sure we trigger once for the new element - this was failing in Safari mobile // Causing a duplicate snapshot if (!evt.detail.elt || !evt.detail.elt.isConnected) return Yoyo.afterSettleActions(evt) } }, // Add support for morphdom swap when using Alpine JS to be able to // maintain the Alpine component state after a swap isInlineSwap: function (swapStyle) { return swapStyle === 'morphdom' }, handleSwap: function (swapStyle, target, fragment) { if (typeof morphdom === 'function' && swapStyle === 'morphdom') { morphdom(target, fragment.outerHTML, { onBeforeElUpdated: (from, to) => { // From Livewire - deal with Alpine component updates if (from.__x) { window.Alpine.clone(from.__x, to) } }, }) return [target] // let htmx handle the new content } }, }) ================================================ FILE: src/yoyo/AnonymousComponent.php ================================================ variables, $this->request->all()); return $this->view($this->componentName, $data); } } ================================================ FILE: src/yoyo/Blade/Application.php ================================================ terminatingCallbacks[] = $callback; return $this; } public function terminate() { foreach ($this->terminatingCallbacks as $terminatingCallback) { $terminatingCallback(); } } } ================================================ FILE: src/yoyo/Blade/CreateBladeViewFromString.php ================================================ createBladeViewFromString($view, $contents); } public function render() { // } } ================================================ FILE: src/yoyo/Blade/YoyoBladeCompilerEngine.php ================================================ yoyoComponent = $component; $this->isRenderingYoyoComponent = true; } public function stopYoyoRendering() { $this->isRenderingYoyoComponent = false; } /** * /vendor/illuminate/view/Engines/PhpEngine.php. */ protected function evaluatePath($__path, $__data) { if (! $this->isRenderingYoyoComponent) { return parent::evaluatePath($__path, $__data); } $obLevel = ob_get_level(); ob_start(); try { \Closure::bind(function () use ($__path, $__data) { extract($__data, EXTR_SKIP); include $__path; }, $this->yoyoComponent ? $this->yoyoComponent : $this)(); } catch (\Throwable $e) { $this->handleViewException($e, $obLevel); } return ltrim(ob_get_clean()); } } ================================================ FILE: src/yoyo/Blade/YoyoBladeDirectives.php ================================================ directive('yoyo', [$this, 'yoyo']); $blade->directive('yoyo_scripts', [$this, 'yoyo_scripts']); $blade->directive('spinning', [$this, 'spinning']); $blade->directive('endspinning', [$this, 'endspinning']); $blade->directive('emit', [$this, 'emit']); $blade->directive('emitTo', [$this, 'emitTo']); $blade->directive('emitToWithSelector', [$this, 'emitToWithSelector']); $blade->directive('emitSelf', [$this, 'emitToWithSelector']); $blade->directive('emitUp', [$this, 'emitToWithSelector']); } public function yoyo($expression) { return <<mount({$expression})->refresh(); } else { echo \$yoyo->mount({$expression})->render(); } ?> yoyo; } public function yoyo_scripts() { return ''; } public function spinning($expression) { return $expression !== '' ? "" : ''; } public function endspinning() { return ''; } public function emit($expression) { return "emit({$expression}); ?>"; } public function emitTo($expression) { return "emitTo({$expression}); ?>"; } public function emitToWithSelector($expression) { return "emitToWithSelector({$expression}); ?>"; } public function emitSelf($expression) { return "emitSelf({$expression}); ?>"; } public function emitUp($expression) { return "emitUp({$expression}); ?>"; } } ================================================ FILE: src/yoyo/Blade/YoyoServiceProvider.php ================================================ registerBladeDirectives(); $this->registerViewCompilerEngine(); } protected function registerViewCompilerEngine() { $this->app->make('view.engine.resolver')->register('blade', function () { return new YoyoBladeCompilerEngine($this->app['blade.compiler']); }); } protected function registerBladeDirectives() { if (method_exists($this->app->get('view'), 'directive')) { $blade = $this->app->get('view'); } else { $blade = $this->app->get('view')->getEngineResolver()->resolve('blade')->getCompiler(); } new YoyoBladeDirectives($blade); } } ================================================ FILE: src/yoyo/Blade/yoyo-view.blade.php ================================================ @yoyo($name, $variables, $attributes, $action) ================================================ FILE: src/yoyo/ClassHelpers.php ================================================ getDefaultProperties(); return static::$defaultVarCache[$cacheKey] = array_intersect_key($values, array_flip($names)); } public static function getPublicVars($instance, $baseClass = null) { $publicProperties = self::getPublicProperties($instance, $baseClass); $vars = call_user_func('get_object_vars', $instance); $publicVars = []; foreach ($vars as $key => $value) { if (in_array($key, $publicProperties)) { $publicVars[$key] = $vars[$key]; } } return $publicVars; } public static function getPublicProperties($instance, $baseClass = null) { $className = get_class($instance); $cacheKey = $className . ':' . ($baseClass ?? ''); if (isset(static::$propertyCache[$cacheKey])) { return static::$propertyCache[$cacheKey]; } $class = new ReflectionClass($className); $properties = $class->getProperties(ReflectionMethod::IS_PUBLIC); $publicProperties = []; foreach ($properties as $prop) { // Only include the property if it's different from the base class when passed as 2d parameter // This allows extending component classes with public properties if (($baseClass && $prop->class !== $baseClass) || $prop->class == $className) { $publicProperties[] = $prop->name; } } return static::$propertyCache[$cacheKey] = $publicProperties; } public static function getPublicMethods($instance, $exceptions = []) { $className = is_string($instance) ? $instance : get_class($instance); $cacheKey = $className . ':' . implode(',', $exceptions); if (isset(static::$methodCache[$cacheKey])) { return static::$methodCache[$cacheKey]; } $class = new ReflectionClass($className); $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC); foreach ($methods as $method) { if ($method->class == $className && ! in_array($method->name, $exceptions)) { $publicMethods[] = $method->name; } } return static::$methodCache[$cacheKey] = $publicMethods ?? []; } public static function methodIsPrivate($instance, $method) { $reflection = new ReflectionMethod($instance, $method); return ! $reflection->isPublic(); } public static function classImplementsInterface($name, $instance) { $class = new ReflectionClass($name); return in_array($instance, $class->getInterfaceNames()); } /** * Laravel Support helper */ public static function classUsesRecursive($class) { if (is_object($class)) { $class = get_class($class); } $className = $class; if (isset(static::$traitCache[$className])) { return static::$traitCache[$className]; } $results = []; foreach (array_reverse(class_parents($class)) + [$class => $class] as $class) { $results += static::traitUsesRecursive($class); } return static::$traitCache[$className] = array_unique($results); } /** * Laravel Support helper */ public static function traitUsesRecursive($trait) { $traits = class_uses($trait); foreach ($traits as $trait) { $traits += static::traitUsesRecursive($trait); } return $traits; } /** * Laravel Support helper */ public static function classBasename($class) { $class = is_object($class) ? get_class($class) : $class; return basename(str_replace('\\', '/', $class)); } public static function getMethodParameterNames($class, $method) { $names = []; $reflector = new ReflectionClass($class); $method = $reflector->getMethod($method); foreach ($method->getParameters() as $parameter) { if (! $parameter->getType() || ($parameter->getType() && $parameter->getType()->isBuiltin())) { $names[] = $parameter->getName(); } } return $names; } public static function methodHasVariadicParameter($class, $method) { $reflector = new ReflectionClass($class); $method = $reflector->getMethod($method); $parameters = $method->getParameters(); if (empty($parameters)) { return false; } // Check if the last parameter is variadic $lastParam = end($parameters); return $lastParam->isVariadic(); } /** * Get all method parameters with type information * Returns an array with 'typed' and 'regular' parameters */ public static function getMethodParametersWithTypes($class, $method) { $className = is_object($class) ? get_class($class) : $class; $cacheKey = $className . ':' . $method; if (isset(static::$paramTypeCache[$cacheKey])) { return static::$paramTypeCache[$cacheKey]; } $typed = []; // Parameters with class type hints (for DI) $regular = []; // Parameters without type hints or with builtin types $reflector = new ReflectionClass($class); $method = $reflector->getMethod($method); foreach ($method->getParameters() as $parameter) { $paramInfo = [ 'name' => $parameter->getName(), 'optional' => $parameter->isOptional(), 'variadic' => $parameter->isVariadic(), ]; if (! $parameter->getType() || ($parameter->getType() && $parameter->getType()->isBuiltin())) { // Regular parameter (no type or builtin type) $regular[] = $paramInfo; } else { // Typed parameter (class type hint for DI) $paramInfo['type'] = $parameter->getType()->getName(); $typed[] = $paramInfo; } } return static::$paramTypeCache[$cacheKey] = [ 'typed' => $typed, 'regular' => $regular, ]; } public static function flushCache(): void { static::$propertyCache = []; static::$defaultVarCache = []; static::$methodCache = []; static::$traitCache = []; static::$paramTypeCache = []; } } ================================================ FILE: src/yoyo/Component.php ================================================ yoyo_id = $id; $this->componentName = $name; $this->request = Yoyo::request(); $this->response = Response::getInstance(); $this->resolver = $resolver; } public function spinning(bool $spinning) { $this->spinning = $spinning; return $this; } public function boot(array $variables, array $attributes) { $data = array_merge($variables, $this->request->all()); $this->variables = $variables; $this->attributes = $attributes; $publicProperties = ClassHelpers::getPublicProperties($this, __CLASS__); foreach ($publicProperties as $property) { $this->{$property} = $data[$property] ?? $this->{$property}; } // Set an initial value for dynamic properties foreach ($this->getDynamicProperties() as $property) { $this->{$property} = $data[$property] ?? null; } return $this; } public function getName() { return $this->componentName; } public function getDynamicProperties() { return []; } public function getInitialAttributes() { $attributes = $this->attributes; return $attributes; } public function getVariables() { return $this->variables; } public function getQueryParam($key, $default = null) { return $this->request->get($key, $default); } public function getQueryString() { return $this->queryString; } public function getProps() { return $this->props; } public function setAction($action) { $this->componentAction = $action; } public function actionMatches($action) { if (is_array($action)) { return in_array($this->componentAction, $action); } return $this->componentAction == $action; } public function getListeners() { $listeners = []; foreach ($this->listeners as $key => $value) { if (is_numeric($key)) { $listeners[$value] = $value; } else { $listeners[$key] = $value; } } return $listeners; } public function getComponentId() { return $this->yoyo_id; } public function set($key, $value = null) { if (is_array($key)) { $this->viewData = array_merge($this->viewData, $key); } else { $this->viewData[$key] = $value; } return $this; } public function render() { if ($this->omitResponse) { throw new BypassRenderMethod($this->response->getStatusCode()); } return $this->view($this->componentName); } public function addSwapModifiers($modifier) { $this->response->header('Yoyo-Swap-Modifier', $modifier); return $this; } public function skipRender() { $this->response->status(204); $this->omitResponse = true; return $this; } public function skipRenderAndRemove($modifier = 'swap:1s') { if ($modifier) { $this->addSwapModifiers($modifier); } $this->response->status(200); $this->omitResponse = true; return $this; } protected function view($template, $vars = []): ViewProviderInterface { $view = $this->resolver->resolveViewProvider(); if (! $view->exists($template)) { throw new MissingComponentTemplate($template, get_class($this)); } $view->startYoyoRendering($this); // Make public properties and methods available to views $vars = array_merge($this->viewVars(), $vars); $view->render($template, $vars); return $view; } public function createViewFromString($content): string { $view = $this->resolver->resolveViewProvider(); $view->startYoyoRendering($this); $html = $view->makeFromString($content, $this->viewVars()); $view->stopYoyoRendering(); return $html; } protected function viewVars(): array { $vars = []; $vars['spinning'] = $this->spinning; $properties = ClassHelpers::getPublicVars($this, __CLASS__); $properties = array_merge($properties, array_fill_keys($this->getDynamicProperties(), null)); return array_merge($this->viewData, $vars, $properties); } protected function createVariableFromMethod(ReflectionMethod $method) { return $method->getNumberOfParameters() === 0 ? $this->createInvocableVariable($method->getName()) : Closure::fromCallable([$this, $method->getName()]); } protected function createInvocableVariable(string $method) { return new InvocableComponentVariable(function () use ($method) { return $this->{$method}(); }); } // For computed properties with arguments // For Twig compatibility, because computed properties are not resolved through __get public function __call(string $name, array $arguments) { $key = static::makeCacheKey($name, $arguments); if (isset($this->computedPropertyCache[$key])) { return $this->computedPropertyCache[$key]; } $studlyProperty = YoyoHelpers::studly($name); if (method_exists($this, $computedMethodName = 'get'.$studlyProperty.'Property')) { return $this->computedPropertyCache[$key] = call_user_func_array([$this, $computedMethodName], $arguments); } throw new ComponentMethodNotFound($this->getName(), $name); } public function __get($property) { if (isset($this->computedPropertyCache[$property])) { return $this->computedPropertyCache[$property]; } $studlyProperty = YoyoHelpers::studly($property); if (method_exists($this, $computedMethodName = 'get'.$studlyProperty.'Property')) { return $this->computedPropertyCache[$property] = $this->$computedMethodName(); } throw new ComponentMethodNotFound($this->getName(), $property); } public function forgetComputed($key = null) { if (is_null($key)) { $this->computedPropertyCache = []; return; } $keys = is_array($key) ? $key : func_get_args(); foreach ($keys as $keyName) { unset($this->computedPropertyCache[$keyName]); } } public function forgetComputedWithArgs($name, ...$args) { $this->forgetComputed(static::makeCacheKey($name, $args)); } protected static function makeCacheKey($name, $arguments) { return md5($name.json_encode($arguments)); } } ================================================ FILE: src/yoyo/ComponentManager.php ================================================ request = $request; $this->spinning = $spinning; $this->resolver = $resolver; } public function getDefaultPublicVars() { return ClassHelpers::getDefaultPublicVars($this->component, Component::class); } public function getPublicVars() { if ($this->isAnonymousComponent()) { return $this->request->except(['component', YoyoCompiler::yoprefix('id')]); } $vars = ClassHelpers::getPublicVars($this->component, Component::class); foreach ($this->component->getDynamicProperties() as $name) { $vars[$name] = property_exists($this->component, $name) ? $this->component->{$name} : null; } $vars = array_merge($vars, $this->request->startsWith(YoyoCompiler::yoprefix(''))); return $vars; } public function getQueryString() { if ($this->isAnonymousComponent()) { return $this->request->method() == 'GET' ? array_keys($this->request->except(['component', YoyoCompiler::yoprefix('id')])) : []; } return $this->component->getQueryString(); } public function getProps() { return $this->component->getProps(); } public function getListeners() { return $this->component->getListeners(); } public function process($id, $name, $action, $variables, $attributes) { if (! ($this->component = $this->resolver->resolveComponent($id, $name, $variables))) { throw new ComponentNotFound($name); } if ($this->isAnonymousComponent()) { return $this->processAnonymousComponent($variables, $attributes); } return $this->processDynamicComponent($action, $variables, $attributes); } public function isAnonymousComponent(): bool { return is_a($this->component, AnonymousComponent::class); } public function isDynamicComponent(): bool { return ! $this->isAnonymousComponent(); } private function processDynamicComponent($action, $variables = [], $attributes = []) { $class = get_class($this->component); $this->component->setAction($action); $isEventListenerAction = false; $eventParams = $this->request->get('eventParams', []); // Guard: Request::get() returns raw string when test_json decodes to falsy value ([], {}) // TODO: Root cause is test_json falsy check in Request::get() — track as separate fix if (is_string($eventParams)) { $decoded = json_decode($eventParams, true, 32); $eventParams = is_array($decoded) ? $decoded : []; } $this->component->spinning($this->spinning)->boot($variables, $attributes); $hookStack = [ 'initialize' => ['initialize'], 'mount' => ['mount'], 'rendering' => ['rendering'], 'rendered' => ['rendered'], ]; $parameters = array_merge($variables, $this->request->all()); // Build stack of trait lifecycle hooks to run after the component hook of the same name foreach (ClassHelpers::classUsesRecursive($this->component) as $trait) { foreach (array_keys($hookStack) as $hook) { $hookStack[$hook][] = $hook.ClassHelpers::classBasename($trait); } } foreach ($hookStack['initialize'] as $method) { if (method_exists($this->component, $method)) { Yoyo::container()->call([$this->component, $method], $parameters); } } $listeners = $this->component->getListeners(); if (! empty($listeners[$action]) || in_array($action, $listeners)) { // If action is an event listener, re-route it to the listener method $action = ! empty($listeners[$action]) ? $listeners[$action] : $action; $isEventListenerAction = true; } elseif (! method_exists($this->component, $action)) { throw new ComponentMethodNotFound($class, $action); } $excludedActions = ClassHelpers::getPublicMethods(Component::class, ['render']); if (in_array($action, $excludedActions) || (! $isEventListenerAction && ClassHelpers::methodIsPrivate($this->component, $action))) { throw new NonPublicComponentMethodCall($class, $action); } foreach ($hookStack['mount'] as $method) { if (method_exists($this->component, $method)) { Yoyo::container()->call([$this->component, $method], $parameters); } } if (! in_array($action, ['render', 'refresh'])) { $parameters = $isEventListenerAction ? $eventParams : $this->parseActionArguments(); // Empty params must fall through to existing no-params handling in the else branch if ($isEventListenerAction && is_array($parameters) && ! empty($parameters) && array_values($parameters) !== $parameters) { // Associative array from JS dispatch — validate required params then pass as named args $paramInfo = ClassHelpers::getMethodParametersWithTypes($this->component, $action); $regularParams = $paramInfo['regular']; foreach ($regularParams as $param) { if (! $param['optional'] && ! $param['variadic'] && ! isset($parameters[$param['name']])) { throw new \InvalidArgumentException( "Missing required parameter [{$param['name']}] for [{$this->name}::{$action}]" ); } } $args = $parameters; } else { // Get parameter information with types $paramInfo = ClassHelpers::getMethodParametersWithTypes($this->component, $action); $regularParams = $paramInfo['regular']; $typedParams = $paramInfo['typed']; // Extract just the names of regular parameters for backwards compatibility $parameterNames = array_column($regularParams, 'name'); // Check if the last regular parameter is variadic $hasVariadic = ! empty($regularParams) && end($regularParams)['variadic']; // Handle variadic parameters if ($hasVariadic && count($parameterNames) > 0) { $regularParamCount = count($parameterNames) - 1; // Exclude the variadic parameter if (count($parameters) >= $regularParamCount) { // Split parameters into regular and variadic $regularParamValues = array_slice($parameters, 0, $regularParamCount); $variadicParamValues = array_slice($parameters, $regularParamCount); // Create args array with named regular parameters and indexed variadic parameters $args = []; for ($i = 0; $i < $regularParamCount; $i++) { $args[$parameterNames[$i]] = $regularParamValues[$i] ?? null; } // Add variadic parameters as indexed values (not named) foreach ($variadicParamValues as $value) { $args[] = $value; } } else { throw new \InvalidArgumentException("Too few parameters passed to [{$this->name}::{$action}]"); } } else { // Check if all regular parameters are optional $requiredCount = 0; foreach ($regularParams as $param) { if (! $param['optional']) { $requiredCount++; } } // Only validate regular parameters (not typed/DI parameters) if (count($parameters) >= $requiredCount && count($parameters) <= count($parameterNames)) { // Parameters count is valid (between required and total) $args = []; for ($i = 0; $i < count($parameterNames); $i++) { $args[$parameterNames[$i]] = $parameters[$i] ?? null; } } elseif (empty($parameterNames) && empty($parameters)) { // Method has only typed parameters (or no parameters at all) $args = []; } else { throw new \InvalidArgumentException("Incorrect number of parameters passed to [{$this->name}::{$action}]"); } } } // The container will handle dependency injection for typed parameters $actionResponse = Yoyo::container()->call([$this->component, $action], $args); $type = gettype($actionResponse); if ($type !== 'string' && $type !== 'NULL') { throw new \Exception("Component [{$class}] action [{$action}] response should be a string, instead was [{$type}]"); } } foreach ($hookStack['rendering'] as $method) { if (method_exists($this->component, $method)) { Yoyo::container()->call([$this->component, $method]); } } $view = $this->component->render(); if (is_null($view)) { return ''; } // For string based templates if (is_string($view)) { $view = $this->component->createViewFromString($view); } foreach ($hookStack['rendered'] as $method) { if (method_exists($this->component, $method)) { $view = Yoyo::container()->call([$this->component, $method], ['view' => $view]); } } return (string) $view; } private function parseActionArguments() { $args = $this->request->get('actionArgs', []); return is_array($args) ? $args : [$args]; } private function processAnonymousComponent($variables = [], $attributes = []) { $this->component->spinning($this->spinning)->boot($variables, $attributes); $view = (string) $this->component->render(); return $view; } public function getComponentInstance() { return $this->component; } } ================================================ FILE: src/yoyo/ComponentResolver.php ================================================ container = $container; $this->registered = $registered; $this->hints = $hints; return $this; } public function getName() { return $this->name; } public function resolving($id, $name, $variables) { } public function resolveComponent($id, $name, $variables): ?Component { $this->resolving($id, $name, $variables); if ($instance = $this->resolveDynamic($id, $name)) { return $instance; } return $this->resolveAnonymous($id, $name); } public function resolveDynamic($id, $name): ?Component { $classNames = []; $args = ['resolver' => $this, 'id' => $id, 'name' => $name]; // Check namespaced components if (strpos($name, ViewProviderInterface::HINT_PATH_DELIMITER) > 0) { [$namespaceAlias, $name] = explode(ViewProviderInterface::HINT_PATH_DELIMITER, $name); if (isset($this->hints[$namespaceAlias])) { foreach ($this->hints[$namespaceAlias] as $namespaceHint) { $classNames[] = $namespaceHint . '\\' . $this->dotNotationToClass($name); } } } $classNames[] = $this->registered[$name] ?? null; $configurationNamespaces = (array) \Clickfwd\Yoyo\Services\Configuration::get('namespace'); foreach ($configurationNamespaces as $namespaceHint) { $classNames[] = $namespaceHint . $this->dotNotationToClass($name); } $classNames = array_filter(array_unique($classNames)); foreach ($classNames as $className) { if (class_exists($className)) { break; } } try { return $this->container->make($className, $args); } catch (ContainerExceptionInterface $e) { return null; } } public function resolveAnonymous($id, $name): ?Component { $args = ['resolver' => $this, 'id' => $id, 'name' => $name]; if ($this->registered[$name] ?? null) { $args['name'] = $this->registered[$name] ?? $name; return $this->container->make(AnonymousComponent::class, $args); } $view = $this->resolveViewProvider(); if ($view->exists($name)) { return $this->container->make(AnonymousComponent::class, $args); } return null; } public function dotNotationToClass($name) { return implode('\\', array_map(function ($name) { return YoyoHelpers::studly($name); }, explode('.', $name))); } public function resolveViewProvider(): ViewProviderInterface { return $this->container->get('yoyo.view.'.$this->getName()); } } ================================================ FILE: src/yoyo/Concerns/BrowserEvents.php ================================================ emit($event, $params); } public function emitTo($target, $event, ...$params) { (BrowserEventsService::getInstance())->emitTo($target, $event, $params); } public function emitToWithSelector($target, $event, ...$params) { (BrowserEventsService::getInstance())->emitToWithSelector($target, $event, $params); } public function emitSelf($event, ...$params) { (BrowserEventsService::getInstance())->emitSelf($event, $params); } public function emitUp($event, ...$params) { (BrowserEventsService::getInstance())->emitUp($event, $params); } public function dispatchBrowserEvent($event, $params = []) { (BrowserEventsService::getInstance())->dispatchBrowserEvent($event, $params); } } ================================================ FILE: src/yoyo/Concerns/Redirector.php ================================================ redirectTo = $url; return $this; } } ================================================ FILE: src/yoyo/Concerns/ResponseHeaders.php ================================================ header('HX-Location', $path); return $this; } public function pushUrl($url) { $this->header('HX-Push-Url', $url); return $this; } public function redirect($url) { $this->header('HX-Redirect', $url); return $this; } public function refresh() { $this->header('HX-Refresh', 'true'); return $this; } public function replaceUrl($url) { $this->header('HX-Replace-Url', $url); return $this; } public function reswap($swap) { $this->header('HX-Reswap', $swap); return $this; } public function reselect($selector) { $this->header('HX-Reselect', $selector); return $this; } public function retarget($selector) { $this->header('HX-Retarget', $selector); return $this; } public function trigger($event) { $this->header('HX-Trigger', $event); return $this; } public function triggerAfterSwap($event) { $this->header('HX-Trigger-After-Swap', $event); return $this; } public function triggerAfterSettle($event) { $this->header('HX-Trigger-After-Settle', $event); return $this; } } ================================================ FILE: src/yoyo/Concerns/Singleton.php ================================================ container = $container; } public function get(string $id) { return $this->container->get($id); } public function has(string $id): bool { return $this->container->has($id); } public function set(string $id, $value) { if (is_object($value) && ! ($value instanceof Closure)) { return $this->container->instance($id, $value); } $this->container->bind($id, $value, true); return $value; } public function make(string $class, array $args = []) { return $this->container->make($class, $args); } public function call($method, array $args = []) { return $this->container->call($method, $args); } } ================================================ FILE: src/yoyo/Containers/YoyoContainer.php ================================================ has($id)) { throw new BindingNotFoundException("[$id] is not bound to the container"); } $resolved = $this->bindings[$id]; if ($resolved instanceof \Closure) { $this->bindings[$id] = $resolved($this); } if (is_string($resolved) && class_exists($resolved)) { return $this->make($resolved); } return $this->bindings[$id]; } public function has(string $id): bool { return isset($this->bindings[$id]); } public function set(string $id, $value) { $this->bindings[$id] = $value; return $value; } public function make(string $class, array $args = []) { try { $class = $this->has($class) ? $this->get($class) : $class; return is_object($class) ? $class : new $class(...$this->extractArgs($class, '__construct', $args)); } catch (\Throwable $e) { throw new ContainerResolutionException("[$class] could not be resolved", $e); } } public function call(callable $method, array $args = []) { if (! is_array($method) || count($method) !== 2) { throw new \InvalidArgumentException("Callable must be in [class, method] format"); } return $method(...$this->extractArgs($method[0], $method[1], $args)); } protected function extractArgs($class, $method, $arguments) { try { $result = []; $reflector = new \ReflectionClass($class); $parameters = $reflector->getMethod($method)->getParameters(); } catch (\ReflectionException $e) { return $arguments; } foreach ($parameters as $parameter) { // Variadic arguments if ($parameter->isVariadic()) { return array_merge($result, array_values($arguments)); } // Named argument elseif (isset($arguments[$parameter->getName()])) { $result[] = $arguments[$parameter->getName()]; unset($arguments[$parameter->getName()]); } // Typed argument elseif ($this->isResolvableType($parameter)) { $result[] = $this->make($parameter->getType()->getName()); } // Argument with default value elseif ($parameter->isDefaultValueAvailable()) { $result[] = $parameter->getDefaultValue(); } // Nullable argument elseif ($parameter->allowsNull()) { $result[] = null; } // Default - assume explicit arguments else { return $arguments; } } return $result; } protected function isResolvableType(\ReflectionParameter $parameter): bool { if ($parameter->getType() instanceof \ReflectionNamedType) { $name = $parameter->getType()->getName(); return $name && (class_exists($name) || interface_exists($name)); } return false; } } ================================================ FILE: src/yoyo/Exceptions/BindingNotFoundException.php ================================================ statusCode = $statusCode; $this->headers = $headers; parent::__construct($message, $statusCode); } public function getStatusCode() { return $this->statusCode; } public function getHeaders() { return $this->headers; } } ================================================ FILE: src/yoyo/Exceptions/IncompleteComponentParamInRequest.php ================================================ callable = $callable; } public function __get($key) { return $this->__invoke()->{$key}; } public function __call($method, $parameters) { return $this->__invoke()->{$method}(...$parameters); } public function __invoke() { return call_user_func($this->callable); } public function __toString() { return (string) $this->__invoke(); } } ================================================ FILE: src/yoyo/QueryString.php ================================================ defaults = $defaults; $this->new = $new; $this->keys = $keys; $this->currentUrl = Yoyo::request()->fullUrl(); } /** * Used to pass variables to the component request. */ public function getQueryParams() { $queryParams = array_merge($this->defaults, $this->new); // Filter out keys that are not explicitly set in the component queryString property $queryParams = array_intersect_key($queryParams, array_flip($this->keys)); return $queryParams; } /** * Used to update the browser URL state. */ public function getPageQueryParams() { if (! $this->currentUrl) { return []; } // Filter out keys that are not explicitly set in the component queryString property $new = array_intersect_key($this->new, array_flip($this->keys)); // Get current query string values and merge them with new ones $queryString = parse_url(htmlspecialchars_decode($this->currentUrl), PHP_URL_QUERY) ?? ''; parse_str($queryString, $args); $queryParams = array_merge($args, $new); // If a query string value matches the default value, remove it from the URL foreach ($queryParams as $key => $val) { if (is_object($val) && method_exists($val, 'toArray')) { $queryParams[$key] = $val->toArray(); } if ((isset($this->defaults[$key]) && $val === $this->defaults[$key]) || $val === '') { unset($queryParams[$key]); } } return $queryParams; } } ================================================ FILE: src/yoyo/Request.php ================================================ request = $_REQUEST; $this->server = $_SERVER; } public function mock($request, $server) { $this->request = $request; $this->server = $server; $this->decodedRequest = null; return $this; } public function reset() { $this->request = []; $this->server = []; $this->decodedRequest = null; } public function all() { if ($this->decodedRequest !== null) { return $this->decodedRequest; } return $this->decodedRequest = array_map(function ($value) { $validJson = false; $decoded = YoyoHelpers::test_json($value, $validJson); if ($validJson) { return $decoded; } return $value; }, $this->request); } public function except($keys) { $keys = is_array($keys) ? $keys : [$keys]; $all = $this->all(); $output = []; foreach ($all as $key => $value) { if (in_array($key, $keys)) { continue; } $output[$key] = $value; } return $output; } public function only($keys) { return array_intersect_key($this->all(), array_flip($keys)); } public function get($key, $default = null) { if (in_array($key, $this->dropped)) { return $default; } $value = $this->request[$key] ?? $default; $validJson = false; $decoded = YoyoHelpers::test_json($value, $validJson); if ($validJson) { return $decoded; } return $value; } public function startsWith($prefix) { $vars = []; foreach ($this->all() as $key => $value) { if (strpos($key, $prefix) === 0) { $vars[$key] = $value; } } return $vars; } public function set($key, $value) { $this->request[$key] = $value; $this->decodedRequest = null; return $this; } public function merge($data) { $this->request = array_merge($this->request, $data); $this->decodedRequest = null; return $this; } public function drop($key) { $this->dropped[] = $key; } public function method() { return $this->server['REQUEST_METHOD'] ?? 'GET'; } public function fullUrl() { if (isset($this->server['HTTP_HX_CURRENT_URL'])) { return $this->server['HTTP_HX_CURRENT_URL']; } if (empty($this->server['HTTP_HOST'])) { return null; } $protocol = 'http'; if (isset($this->server['HTTPS']) && $this->server['HTTPS'] === 'on') { $protocol = 'https'; } $host = $this->server['HTTP_HOST']; $path = rtrim($this->server['REQUEST_URI'] ?? '', '?'); return "{$protocol}://{$host}{$path}"; } public function isYoyoRequest() { return $this->server['HTTP_HX_REQUEST'] ?? false; } public function windUp() { unset($this->server['HTTP_HX_REQUEST']); } public function triggerId() { return $this->server['HTTP_HX_TRIGGER']; } public function triggerName() { return $this->server['HTTP_HX_TRIGGER_NAME'] ?? null; } public function header($name) { return $this->server['HTTP_'.strtoupper($name)] ?? null; } } ================================================ FILE: src/yoyo/Services/BrowserEventsService.php ================================================ request = Yoyo::request(); $this->response = Response::getInstance(); } public function emit($event, ...$params) { $this->queue($event, $params); } public function emitTo($target, $event, ...$params) { $this->queue($event, $params, null, $target); } public function emitToWithSelector($target, $event, ...$params) { $this->queue($event, $params, $target); } public function emitSelf($event, ...$params) { if ($component = $this->getComponentNameFromRequest()) { $this->queue($event, $params, $selector = null, $component, 'self'); } } public function emitUp($event, ...$params) { $targetId = $this->request->triggerId(); if ($component = $this->getComponentNameFromRequest()) { $this->queue($event, $params, "#{$targetId}", $component, 'ancestorsOnly'); } } public function queue($event, $params, $selector = null, $component = null, $propagation = null) { $params = $params[0]; $payload = array_filter(compact('event', 'params', 'selector', 'component', 'propagation')); $this->eventQueue[] = $payload; } public function dispatchBrowserEvent($event, $params = []) { $this->browserEventQueue[] = compact('event', 'params'); } public function dispatch() { $this->response->header('Yoyo-Emit', json_encode($this->eventQueue)); $this->response->header('Yoyo-Browser-Event', json_encode($this->browserEventQueue)); } protected function getComponentNameFromRequest() { if ($name = $this->request->get('component')) { return explode('/', $name)[0]; } return false; } } ================================================ FILE: src/yoyo/Services/Configuration.php ================================================ 'App\\Yoyo\\', 'defaultSwapStyle' => 'outerHTML', 'historyEnabled' => false, 'indicatorClass' => 'yoyo-indicator', 'requestClass' => 'yoyo-request', 'settlingClass' => 'yoyo-settling', 'swappingClass' => 'yoyo-swapping', ], $options); } public static function get($key, $default = null) { return self::$options[$key] ?? $default; } public static function scripts($return = false) { return self::minify(self::javascriptAssets()); } public static function styles() { return self::minify(self::cssStyle()); } public static function htmxSrc(): string { if (empty($htmxSrc = self::get('htmx'))) { $htmxSrc = 'https://unpkg.com/htmx.org@'.self::$htmx.'/dist/htmx.min.js'; } return $htmxSrc; } public static function yoyoSrc(): string { return rtrim(self::get('scriptsPath', ''), '/').'/yoyo.js'; } public static function javascriptAssets(): string { $htmxSrc = self::htmxSrc(); $yoyoSrc = self::yoyoSrc(); $initCode = self::javascriptInitCode(); return << {$initCode} HTML; } public static function javascriptInitCode($includeScriptTag = true): string { $yoyoRoute = self::get('url', ''); $configuration = array_intersect_key(static::$options, array_flip(static::$allowedConfigOptions)); $yoyoConfig = json_encode($configuration); $yoyoRouteJs = json_encode($yoyoRoute); $script = <<response = Response::getInstance(); } public function redirect($url) { if ($url) { $this->response->header('Yoyo-Redirect', $url); } } } ================================================ FILE: src/yoyo/Services/Response.php ================================================ headers[$name] = $value; return $this; } public function status($statusCode) { $this->statusCode = $statusCode; return $this; } public function send(string $content = ''): string { if (! headers_sent()) { foreach ($this->headers as $key => $value) { if (is_array($value)) { $value = json_encode($value); } header("$key: $value"); } http_response_code($this->statusCode ?? 200); } return $content ?: ''; } public function setHeaders($headers) { $this->headers = array_merge($this->headers, $headers); return $this; } public function getHeaders() { return $this->headers; } public function getStatusCode() { return $this->statusCode; } } ================================================ FILE: src/yoyo/Services/UrlStateManagerService.php ================================================ request = Yoyo::request(); $this->currentUrl = $this->request->fullUrl(); } public function pushState($queryParams) { $response = Response::getInstance(); if (! $this->currentUrl || $this->request->method() !== 'GET') { return; } // Don't override if the component already set an explicit URL $headers = $response->getHeaders(); if (isset($headers['HX-Replace-Url']) || isset($headers['HX-Push-Url'])) { return; } $parsedUrl = parse_url($this->currentUrl); $port = isset($parsedUrl['port']) ? (':'.$parsedUrl['port']) : ''; $url = $parsedUrl['scheme'].'://'.$parsedUrl['host'].$port.$parsedUrl['path'].($queryParams ? '?'.http_build_query($queryParams) : ''); if ($url !== $this->currentUrl) { $response->header('Yoyo-Push', $url); } } } ================================================ FILE: src/yoyo/Twig/YoyoTwigExtension.php ================================================ yoyo_scripts(), $this->yoyo(), $this->emit(), $this->emitTo(), $this->emitToWithSelector(), $this->emitSelf(), $this->emitUp(), ]; } public function getGlobals(): array { return [ ]; } private function yoyo_scripts() { return new TwigFunction('yoyo_scripts', function (): Markup { return self::raw(yoyo_scripts()); }); } private function yoyo() { return new TwigFunction('yoyo', function ($name, $variables = [], $attributes = []): Markup { $variables = $variables ?? []; $attributes = $attributes ?? []; $output = yoyo_render($name, $variables, $attributes); return self::raw($output); }); } private function emit() { return new TwigFunction('emit', function ($eventName, $payload = []) { (BrowserEventsService::getInstance())->emit($eventName, $payload); }); } private function emitTo() { return new TwigFunction('emitTo', function ($target, $eventName, $payload = []) { (BrowserEventsService::getInstance())->emitTo($target, $eventName, $payload); }); } private function emitToWithSelector() { return new TwigFunction('emitToWithSelector', function ($target, $eventName, $payload = []) { (BrowserEventsService::getInstance())->emitToWithSelector($target, $eventName, $payload); }); } private function emitSelf() { return new TwigFunction('emitSelf', function ($eventName, $payload = []) { (BrowserEventsService::getInstance())->emitSelf($eventName, $payload); }); } private function emitUp() { return new TwigFunction('emitUp', function ($eventName, $payload = []) { (BrowserEventsService::getInstance())->emitUp($eventName, $payload); }); } private static function raw($string) { return new Markup($string, 'UTF-8'); } } ================================================ FILE: src/yoyo/View.php ================================================ paths = array_map([$this, 'resolvePath'], $paths); } /** * Forward method calls to their property closure function equivalent * Used for eventManager emit methods dynamically added to the view class. */ public function __call($name, $args) { return call_user_func_array($this->$name, $args); } public function startYoyoRendering($component) { $this->yoyoComponent = $component; return $this; } public function render($name, $vars = []): string { $path = $this->exists($name); ob_start(); \Closure::bind(function () use ($path, $vars) { extract($vars, EXTR_SKIP); include $path; }, $this->yoyoComponent ? $this->yoyoComponent : $this)(); return ltrim(ob_get_clean()); } public function makeFromString($content, $vars = []): string { throw new \Exception('Views from strings are not supported with the native Yoyo view provider.'); } public function exists($name) { if (isset($this->views[$name])) { return $this->views[$name]; } if ($this->hasHintInformation($name = trim($name))) { return $this->views[$name] = $this->findNamespacedView($name); } return $this->views[$name] = $this->findInPaths($name, $this->paths); } public function addLocation($location) { $this->paths[] = $this->resolvePath($location); } public function prependLocation($location) { array_unshift($this->paths, $this->resolvePath($location)); } protected function findInPaths($name, $paths) { $templatePath = str_replace('.', '/', $name); foreach ($paths as $path) { if (file_exists($location = "{$path}/{$templatePath}.php")) { return $location; } } throw new InvalidArgumentException("View [{$name}] not found."); } protected function findNamespacedView($name) { [$namespace, $view] = $this->parseNamespaceSegments($name); return $this->findInPaths($view, static::$hints[$namespace]); } protected function parseNamespaceSegments($name) { $segments = explode(ViewProviderInterface::HINT_PATH_DELIMITER, $name); if (count($segments) !== 2) { throw new InvalidArgumentException("View [{$name}] has an invalid name."); } if (! isset(static::$hints[$segments[0]])) { throw new InvalidArgumentException("No hint path defined for [{$segments[0]}]."); } return $segments; } public function addNamespace($namespace, $hints) { $hints = (array) $hints; if (isset(static::$hints[$namespace])) { $hints = array_merge(static::$hints[$namespace], $hints); } static::$hints[$namespace] = $hints; } public function prependNamespace($namespace, $hints) { $hints = (array) $hints; if (isset(static::$hints[$namespace])) { $hints = array_merge($hints, static::$hints[$namespace]); } static::$hints[$namespace] = $hints; } public function hasHintInformation($name) { return strpos($name, ViewProviderInterface::HINT_PATH_DELIMITER) > 0; } protected function resolvePath($path) { return realpath($path) ?: $path; } } ================================================ FILE: src/yoyo/ViewProviders/BaseViewProvider.php ================================================ view; } } ================================================ FILE: src/yoyo/ViewProviders/BladeViewProvider.php ================================================ view = $view; } public function startYoyoRendering($component): void { $this->engine = $this->view->getContainer()->get('view.engine.resolver')->resolve('blade'); $this->engine->startYoyoRendering($component); } public function stopYoyoRendering(): void { $this->engine->stopYoyoRendering(); } public function render($template, $vars = []): ViewProviderInterface { $this->template = $template; $this->vars = $vars; return $this; } public function makeFromString($content, $vars = []): string { $view = $this->view->make((new \Clickfwd\Yoyo\Blade\CreateBladeViewFromString())($this->view, $content)); return $view->with($vars)->render(); } public function exists($template): bool { return $this->view->exists($template); } public function getFinder() { return $this->view->getFinder(); } public function addNamespace($namespace, $hints) { $this->getFinder()->addNamespace($namespace, $hints); return $this; } public function prependNamespace($namespace, $hints) { $this->getFinder()->prependNamespace($namespace, $hints); return $this; } public function addLocation($location) { $this->getFinder()->addLocation($location); return $this; } public function prependLocation($location) { $this->getFinder()->prependLocation($location); return $this; } public function __call(string $method, array $params) { return call_user_func_array([$this->view, $method], $params); } public function __toString() { $output = (string) $this->view->make($this->template, $this->vars); $this->stopYoyoRendering(); return $output; } } ================================================ FILE: src/yoyo/ViewProviders/PhalconViewProvider.php ================================================ view = $view; } public function exists($view): bool { return file_exists($this->view->getViewsDir() . $view . $this->viewExtention); } public function render($template, $vars = []): ViewProviderInterface { $this->template = $template; $this->vars = $vars; return $this; } public function setViewExtention($viewExtention) { $this->viewExtention = $viewExtention; return $this; } public function makeFromString($content, $vars = []): string { $this->view->start(); $this->view->setContent($content); $this->view->setVars($vars); $this->view->finish(); return $this->view->render(); } public function startYoyoRendering($component): void { } public function stopYoyoRendering(): void { } public function __toString() { return $this->view->render($this->template, $this->vars); } } ================================================ FILE: src/yoyo/ViewProviders/TwigViewProvider.php ================================================ view = $view; } public function startYoyoRendering($component): void { $this->yoyoComponent = $component; } public function stopYoyoRendering(): void { // } public function normalizeName($template) { if (strpos($template, ViewProviderInterface::HINT_PATH_DELIMITER) > 0) { [$namespace, $name] = explode(ViewProviderInterface::HINT_PATH_DELIMITER, $template); return "@{$namespace}/{$name}"; } return $template; } public function render($template, $vars = []): ViewProviderInterface { $this->template = $this->normalizeName($template); $this->vars = $vars; return $this; } public function makeFromString($content, $vars = []): string { $template = $this->view->createTemplate((string) $content); return $template->render($vars); } public function exists($template): bool { $template = $this->normalizeName($template); return $this->getLoader()->exists($template.'.'.self::$twig_template_extension); } public function getLoader() { return $this->view->getLoader(); } public function addNamespace($namespace, $path) { $this->getLoader()->addPath($path, $namespace); return $this; } public function prependNamespace($namespace, $path) { $this->getLoader()->prependPath($path, $namespace); return $this; } public function addLocation($location) { $this->getLoader()->addPath($location); return $this; } public function prependLocation($location) { $this->getLoader()->prependPath($location); return $this; } public function __call(string $method, array $params) { return call_user_func_array([$this->view, $method], $params); } public function __toString() { $this->vars['this'] = $this->yoyoComponent; return (string) $this->view->render($this->template.'.'.self::$twig_template_extension, $this->vars); } } ================================================ FILE: src/yoyo/ViewProviders/YoyoViewProvider.php ================================================ view = $view; } public function startYoyoRendering($component): void { $this->view->startYoyoRendering($component); } public function stopYoyoRendering(): void { // } public function render($name, $vars = []): ViewProviderInterface { $this->name = $name; $this->vars = $vars; return $this; } public function makeFromString($content, $vars = []): string { return $this->view->makeFromString($content, $vars); } public function exists($name): bool { try { return $this->view->exists($name); } catch (InvalidArgumentException $e) { throw new ComponentNotFound($name); } } public function addNamespace($namespace, $hints) { $this->view->addNamespace($namespace, $hints); return $this; } public function prependNamespace($namespace, $hints) { $this->view->prependNamespace($namespace, $hints); return $this; } public function addLocation($location) { $this->view->addLocation($location); return $this; } public function prependLocation($location) { $this->view->prependLocation($location); return $this; } public function __call(string $method, array $params) { return call_user_func_array([$this->view, $method], $params); } public function __toString() { return $this->view->render($this->name, $this->vars); } } ================================================ FILE: src/yoyo/Yoyo.php ================================================ get(YoyoCompiler::yoprefix_value('id'), YoyoCompiler::yoprefix_value(YoyoHelpers::randString())); } // Remove the component ID from the request so it's not passed to child components static::request()->drop(YoyoCompiler::yoprefix_value('id')); return $id; } private function getComponentResolver() { $name = $this->variables[YoyoCompiler::yoprefix('resolver')] ?? static::request()->get(YoyoCompiler::yoprefix('resolver')); if ($name && static::$container->has("yoyo.resolver.{$name}")) { return static::$container->get("yoyo.resolver.{$name}")(static::$container, static::$registeredComponents, static::$componentNamespaces); } $resolver = ! $name ? new ComponentResolver() : static::$resolverInstances[$name]; $name = $name ?? 'default'; static::$container->set("yoyo.resolver.{$name}", $resolver); return $resolver(static::$container, static::$registeredComponents, static::$componentNamespaces); } public function registerViewProvider($name, $provider = null) { if (is_null($provider)) { $provider = $name; $name = 'default'; } static::$container->set("yoyo.view.{$name}", $provider); } public function registerViewProviders($providers) { foreach ($providers as $name => $provider) { $this->registerViewProvider($name, $provider); } } public static function getViewProvider($name = 'default') { return static::$container->get("yoyo.view.{$name}"); } public function registerComponentResolver($resolver) { if (is_string($resolver)) { $resolver = self::$container->make($resolver); } $this->registerViewProvider($name = $resolver->getName(), function () use ($resolver) { return $resolver->getViewProvider(); }); static::$resolverInstances[$name] = $resolver; } /** * Register component namespace hints * * @param string $namespace * @param mixed $class * @return void */ public static function componentNamespace(string $namespace, $class): void { $class = array_filter((array) $class); static::$componentNamespaces[$namespace] = array_merge(static::$componentNamespaces[$namespace] ?? [], $class); } public static function registerComponent($name, $class = null): void { static::$registeredComponents[$name] = $class; } public static function registerComponents($components): void { foreach ($components as $name => $class) { if (is_numeric($name)) { $name = $class; $class = null; } static::registerComponent($name, $class); } } public static function abort($code, $message = '', array $headers = []) { if ($code == 404) { throw new NotFoundHttpException($message, $headers); } throw new HttpException($code, $message, $headers); } public function mount($name, $variables = [], $attributes = [], $action = 'render'): self { $this->action($action); $this->id = $this->getComponentId($attributes); unset($attributes['id']); $this->name = $name; $this->variables = $variables; $this->attributes = $attributes; return $this; } public function action($action): self { $this->action = $action == 'refresh' ? 'render' : $action; return $this; } public function actionArgs(...$args) { $this->request()->merge(['actionArgs' => $args]); return $this; } /** * Renders the component on initial page load. */ public function render(): string { return $this->output($spinning = false); } /** * Renders the component on dynamic updates (ajax) to send back to the browser. */ public function refresh(): string { $output = $this->output($spinning = true); return $output; } public function update(): string { [$name, $action] = $this->parseUpdateRequest(); return $this->mount($name, $variables = [], $attributes = [], $action)->refresh(); } protected function parseUpdateRequest() { $component = static::request()->get('component'); $parts = array_filter(explode('/', $component)); if (empty($parts)) { throw new Exceptions\IncompleteComponentParamInRequest(); } $name = $parts[0]; $action = $parts[1] ?? 'render'; return [$name, $action]; } public function output($spinning = false) { $variables = []; $componentManager = new ComponentManager($this->getComponentResolver(), static::request(), $spinning); try { try { $html = $componentManager->process($this->id, $this->name, $this->action ?? YoyoCompiler::COMPONENT_DEFAULT_ACTION, $this->variables, $this->attributes); } finally { if ($componentManager->getComponentInstance()) { // Get all data needed to pass the rendered HTML through the Yoyo compiler to make it reactive $defaultValues = $componentManager->getDefaultPublicVars(); $newValues = $componentManager->getPublicVars(); // Get dynamic component public properties anonymous components vars to pass them to the compiler // Any matching parameter names in yoyo:props will be automatically added to yoyo:vals $variables = array_merge($defaultValues, $newValues); $variables = YoyoHelpers::removeEmptyValues($variables); $listeners = $componentManager->getListeners(); $componentType = $componentManager->isDynamicComponent() ? 'dynamic' : 'anonymous'; // For dynamic components, filter variables based on component props $props = $componentManager->getProps(); $postComponentProcessingActions = function () use ($componentManager, $defaultValues, $newValues) { $queryStringKeys = $componentManager->getQueryString(); $queryString = new QueryString($defaultValues, $newValues, $queryStringKeys); // Browser URL State $urlStateManager = new UrlStateManagerService(); if ($componentManager->isDynamicComponent()) { $urlStateManager->pushState($queryString->getPageQueryParams()); } // Browser Events $eventsService = BrowserEventsService::getInstance(); $eventsService->dispatch(); // Browser Redirect (PageRedirectService::getInstance())->redirect($componentManager->getComponentInstance()->redirectTo); }; } } } catch (BypassRenderMethod $e) { if (isset($postComponentProcessingActions)) { $postComponentProcessingActions(); } if ($e->getCode() == 204) { Response::getInstance()->status(204)->send(); // Need to throw exception to stop execution and send 204 status code throw $e; } else { return Response::getInstance()->status(200)->send(); } } catch (HttpException $e) { if (isset($postComponentProcessingActions)) { $postComponentProcessingActions(); } Response::getInstance()->status($e->getStatusCode())->setHeaders($e->getHeaders())->send(); throw $e; } catch (\Exception $e) { if (isset($postComponentProcessingActions)) { $postComponentProcessingActions(); } Response::getInstance()->send(); throw $e; } $cacheHistory = ! empty(array_filter( $componentManager->getQueryString(), function ($key) { return strpos($key, 'yoyo-id') !== 0; } ) ); $compiledHtml = $this->compile($componentType, $html, $spinning, $variables, $listeners, $props, $cacheHistory); if ($spinning) { $postComponentProcessingActions(); } return (Response::getInstance())->send($compiledHtml); } public function compile($componentType, $html, $spinning = null, $variables = [], $listeners = [], $props = [], $cacheHistory = false): string { $spinning = $spinning ?? $this->is_spinning(); $variables = array_merge($this->variables, $variables); $output = (new YoyoCompiler($componentType, $this->id, $this->name, $variables, $this->attributes, $spinning)) ->withHistory($cacheHistory) ->addComponentListeners($listeners) ->addComponentProps($props) ->compile($html); return $output; } /** * Is this a request to update the component? */ private function is_spinning(): bool { $spinning = static::request()->isYoyoRequest(); // Stop spinning of child components when parent is refreshed static::request()->windUp(); return $spinning; } } ================================================ FILE: src/yoyo/YoyoCompiler.php ================================================ 'trigger', ]; public const COMPONENT_DEFAULT_ACTION = 'render'; public const COMPONENT_WRAPPER_CLASS = 'yoyo-wrapper'; public const YOYO_PREFIX = 'yoyo'; public const YOYO_PREFIX_FINDER = 'yoyo-finder'; public const HTMX_PREFIX = 'hx'; // Pre-built lookup tables for O(1) checks instead of in_array() private static $yoyoAttributeMap; private static $htmxMethodMap; // Cached prefix strings to avoid repeated concatenation private static $yoprefixCache = []; private static $hxprefixCache = []; public function __construct($componentType, $componentId, $name, $variables, $attributes, $spinning) { $this->componentType = $componentType; $this->componentId = $componentId; $this->name = $name; $this->variables = $variables; $this->attributes = $attributes; $this->spinning = $spinning; // Build lookup maps once if (self::$yoyoAttributeMap === null) { self::$yoyoAttributeMap = array_flip(self::YOYO_ATTRIBUTES); self::$htmxMethodMap = array_flip(self::HTMX_REQUEST_METHOD_ATTRIBUTES); } } public function addComponentListeners($listeners = []) { $this->listeners = $listeners; return $this; } public function addComponentProps($props = []) { $this->props = $props; return $this; } public function withHistory($cacheHistory = false) { $this->withHistory = $cacheHistory; return $this; } public function compile($html): string { if (! trim($html)) { return $html; } // Add yoyo-finder marker attributes for XPath discovery // (XPath cannot query attributes with colons) // Combined regex handles both double and single quoted attributes $prefix = self::YOYO_PREFIX; $prefix_finder = self::YOYO_PREFIX_FINDER; $html = preg_replace( [ '/ '.$prefix.':(.*)="(.*)"/U', '/ '.$prefix.':(.*)=\'(.*)\'/U', ], [ " $prefix_finder $prefix:\$1=\"\$2\"", " $prefix_finder $prefix:\$1='\$2'", ], $html ); // Convert non-ASCII characters to numeric HTML entities only when needed if (preg_match('/[\x80-\xff]/', $html)) { $html = mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, ~0], 'UTF-8'); } $dom = new DOMDocument(); $internalErrors = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_use_internal_errors($internalErrors); // Reuse a single XPath instance throughout $xpath = new DOMXPath($dom); if (! ($node = $this->getComponentRootNode($xpath))) { $html = $this->getOuterHTML($dom, $xpath); unset($dom); return $this->compile('
'.$html.'
'); } $elements = $xpath->query('//form'); foreach ($elements as $element) { if (! $element->hasAttribute(self::yoprefix('ignore'))) { $this->addFormBehavior($element, $xpath); } } // Prevent infinite loop with on 'load' event on root node with outerHTML swap $this->removeOnLoadEventWhenSpinning($node); $this->addComponentRootAttributes($node); $this->addComponentChildrenAttributes($xpath); // Cleanup $node->removeAttribute(self::YOYO_PREFIX_FINDER); $doOuterHtmlSwap = ! $this->elementHasAttributeWithValue($node, self::hxprefix('swap'), 'innerHTML'); if ($this->spinning && ! $doOuterHtmlSwap) { $output = $this->getInnerHTML($dom, $xpath); } else { $output = $this->getOuterHTML($dom, $xpath); } return trim($output); } protected function addComponentRootAttributes($element) { if ($element->hasAttribute(self::yoprefix('ignore'))) { $element->removeAttribute(self::yoprefix('ignore')); return; } // Skip when component already compiled if ($element->hasAttribute(self::yoprefix('name')) && $element->hasAttribute(self::hxprefix('vals'))) { return; } $element->setAttribute(self::YOYO_PREFIX, ''); $element->setAttribute(self::YOYO_PREFIX_FINDER, ''); // Discard generated component ID and use hardcoded one if found $id = $element->getAttribute('id'); if ($id !== '') { $this->componentId = $id; } $this->addRequestMethodAttribute($element, true); // Get default attributes $attributes = $this->getComponentAttributes($this->componentId); // Merge, or in some cases replace, defaults with existing attributes at the root node level if (! $element->hasAttribute('id')) { $element->setAttribute('id', $this->componentId); } foreach (['target', 'include'] as $attr) { if ($value = $element->getAttribute(self::yoprefix($attr))) { $attributes[$attr] = $value; } } // Add yoyo extension attribute and merge existing extensions if ($ext = $element->getAttribute(self::yoprefix('ext'))) { $element->removeAttribute(self::yoprefix('ext')); $attributes['ext'] .= ', '.$ext; } $class = $element->getAttribute('class'); $element->setAttribute('class', self::COMPONENT_WRAPPER_CLASS.($class ? ' '.$class : '')); $element->setAttribute(self::yoprefix('name'), $this->name); if ($this->withHistory) { $element->setAttribute(self::yoprefix('history'), 1); } if ($trigger = $element->getAttribute(self::yoprefix('on'))) { $attributes['on'] .= ', '.$trigger; } // Process variables if ($vars = $element->getAttribute(self::yoprefix('vals'))) { $element->removeAttribute(self::yoprefix('vals')); $vars = YoyoHelpers::decode_vals($vars); $attributes['vals'] = array_merge($attributes['vals'], $vars); } // Process invididual variables added through yoyo:val.key $attributes['vals'] = array_merge($attributes['vals'] ?? [], $this->parseIndividualValAttributes($element)); // Process public props if ($props = $element->getAttribute(self::yoprefix('props')) ?: []) { $props = explode(',', str_replace(' ', '', $props)); $element->removeAttribute(self::yoprefix('props')); } $props = array_merge($props, $this->props, [ self::yoprefix('resolver'), self::yoprefix('source'), ]); $propsLookup = []; foreach ($props as $prop) { if (is_string($prop) && $prop !== '') { $propsLookup[$prop] = true; } } $variables = array_filter($this->variables, function ($key) use ($propsLookup) { return isset($propsLookup[$key]); }, ARRAY_FILTER_USE_KEY); $attributes['vals'] = array_merge($attributes['vals'], $variables); // Add all attributes $attributes['vals'] = YoyoHelpers::encode_vals($attributes['vals']); foreach ($attributes as $attr => $value) { if (! $value) { $value = $element->getAttribute(self::yoprefix($attr)); } if ($value) { $this->remapAndReplaceAttribute($element, $attr, $value); } } } protected function addComponentChildrenAttributes($xpath) { $elements = $xpath->query('//*[@'.self::YOYO_PREFIX.']|//*[@'.self::YOYO_PREFIX_FINDER.']'); $valPrefixLen = strlen(self::yoprefix('val').'.'); // "yoyo:val." = 9 foreach ($elements as $key => $element) { // Skip the component root because it's processed separately if ($key == 0) { continue; } // Skip already-compiled nested components. // Keep cleanup behavior consistent with the existing loop. if ($element->hasAttribute(self::yoprefix('name')) && $element->hasAttribute(self::hxprefix('vals'))) { $element->removeAttribute(self::YOYO_PREFIX); $element->removeAttribute(self::YOYO_PREFIX_FINDER); continue; } // Single pass over element attributes: categorize everything at once // instead of 3 separate passes (addRequestMethodAttribute + yoyo scan + val scan) $yoyoAttrs = []; $valAttrs = []; $valToRemove = []; $hasHxMethod = false; $yoyoMethod = null; $yoyoMethodValue = null; $hasYoyoMarker = false; foreach ($element->attributes as $attr) { $name = $attr->name; if ($name === self::YOYO_PREFIX) { $hasYoyoMarker = true; continue; } // Check for existing hx-{method} — skip method assignment if found if (str_starts_with($name, 'hx-') && isset(self::$htmxMethodMap[substr($name, 3)])) { $hasHxMethod = true; continue; } if (! str_starts_with($name, 'yoyo:')) { continue; } $yoyoAttr = substr($name, 5); // yoyo:val.{key} — collect for val processing if (str_starts_with($yoyoAttr, 'val.')) { $valKey = substr($name, $valPrefixLen); $valAttrs[YoyoHelpers::camel($valKey, '-')] = YoyoHelpers::decode_val($attr->value); $valToRemove[] = $name; continue; } // yoyo:{method} — request method to remap if (isset(self::$htmxMethodMap[$yoyoAttr])) { $yoyoMethod = $yoyoAttr; $yoyoMethodValue = $attr->value; continue; } // yoyo:{attr} — collect for hx- remapping if (isset(self::$yoyoAttributeMap[$yoyoAttr])) { $yoyoAttrs[$yoyoAttr] = $attr->value; } } // Handle request method if (! $hasHxMethod) { if ($yoyoMethod !== null) { $element->removeAttribute(self::yoprefix($yoyoMethod)); $element->setAttribute(self::hxprefix($yoyoMethod), $yoyoMethodValue); $this->checkForIdAttribute($element); } elseif ($hasYoyoMarker) { $element->setAttribute(self::hxprefix('get'), self::COMPONENT_DEFAULT_ACTION); $this->checkForIdAttribute($element); } } // Remap yoyo: attributes to hx- foreach ($yoyoAttrs as $yoyoAttr => $value) { $this->remapAndReplaceAttribute($element, $yoyoAttr, $value); } // Process val attributes if ($valAttrs) { foreach ($valToRemove as $name) { $element->removeAttribute($name); } $element->setAttribute(self::hxprefix('vals'), YoyoHelpers::encode_vals($valAttrs)); } // Cleanup $element->removeAttribute(self::YOYO_PREFIX); $element->removeAttribute(self::YOYO_PREFIX_FINDER); } } protected function parseIndividualValAttributes($element) { $attributes = []; $valPrefix = self::yoprefix('val').'.'; $valPrefixLen = strlen($valPrefix); // Collect matching attributes first to avoid modifying // the live DOMNamedNodeMap while iterating $toRemove = []; foreach ($element->attributes as $attr) { $name = $attr->name; if (! str_starts_with($name, $valPrefix)) { continue; } $key = substr($name, $valPrefixLen); $attributes[YoyoHelpers::camel($key, '-')] = YoyoHelpers::decode_val($attr->value); $toRemove[] = $name; } foreach ($toRemove as $name) { $element->removeAttribute($name); } return $attributes; } protected function removeOnLoadEventWhenSpinning($element) { if ($this->spinning && $element->hasAttribute(self::yoprefix('on'))) { $on = $element->getAttribute(self::yoprefix('on')); $events = explode(',', $on); $events = array_filter($events, function ($event) { return $event !== 'load'; }); $element->setAttribute(self::yoprefix('on'), implode(',', $events)); } } protected function addFormBehavior($element, $xpath = null) { if ($element->tagName == 'form' && ! $element->hasAttribute(self::yoprefix('on'))) { $element->setAttribute(self::YOYO_PREFIX, ''); $element->setAttribute(self::yoprefix('on'), 'submit'); // If the form has an upload input, set the encoding to multipart/form-data if ($xpath === null) { $xpath = new DOMXPath($element->ownerDocument); } $inputs = $xpath->query('.//*[@type="file"]', $element); if ($inputs->item(0)) { $element->setAttribute(self::yoprefix('encoding'), 'multipart/form-data'); } // If the form tag doesn't have a method set, set POST by default foreach ($element->attributes as $attr) { if (($parts = explode(':', $attr->name))[0] == self::YOYO_PREFIX && ! empty($parts[1])) { if (isset(self::$htmxMethodMap[$parts[1]])) { return; } } } $element->setAttribute(self::yoprefix('post'), self::COMPONENT_DEFAULT_ACTION); } } protected function checkForIdAttribute($element) { if (! $element->hasAttribute('id')) { $element->setAttribute('id', $this->componentId.'-'.$this->idCounter++); } } protected function remapAndReplaceAttribute($element, $attr, $value) { if (str_starts_with($attr, self::YOYO_PREFIX) || isset(self::$yoyoAttributeMap[$attr])) { $element->removeAttribute(self::yoprefix($attr)); $remappedAttr = self::YOYO_TO_HX_ATTRIBUTE_REMAP[$attr] ?? $attr; $element->setAttribute(self::hxprefix($remappedAttr), $value); } elseif ($value) { $currentValue = $element->getAttribute($attr); if ($currentValue) { $value = $currentValue . ' ' . $value; } $element->setAttribute($attr, $value); } } protected function addRequestMethodAttribute($element, $isRootNode = false) { // Skip if element already has an hx-[request] attribute (no yoyo:[request] which is processed below) foreach (self::HTMX_REQUEST_METHOD_ATTRIBUTES as $attr) { $hxattr = self::hxprefix($attr); if ($element->hasAttribute($hxattr)) { return; } } // Look for existing method attribute, otherwise set 'get' as default foreach (self::HTMX_REQUEST_METHOD_ATTRIBUTES as $attr) { $yoattr = self::yoprefix($attr); if ($value = $element->getAttribute($yoattr)) { $element->removeAttribute($yoattr); $element->setAttribute(self::hxprefix($attr), $value); // Add an ID attribute for elements that trigger requests if (! $isRootNode) { $this->checkForIdAttribute($element); } return; } } // Automatically add the default hx-get="render" request to component root nodes and any child with the `yoyo` attribute if ($element->hasAttribute(self::YOYO_PREFIX)) { $element->setAttribute(self::hxprefix('get'), self::COMPONENT_DEFAULT_ACTION); if (! $isRootNode) { // Ensure re-active tags have an ID to improve swapping $this->checkForIdAttribute($element); } } } protected function getComponentAttributes($componentId): array { $attributes = array_merge( array_fill_keys(self::YOYO_ATTRIBUTES, ''), [ 'ext' => 'yoyo', // Adding refresh trigger to prevent default click trigger 'on' => 'refresh', 'target' => 'this', 'include' => "this", 'vals' => [self::yoprefix_value('id') => $componentId], ], $this->attributes ); // Include component listeners in trigger attribute if (! empty($this->listeners)) { $listeners = array_keys($this->listeners); $attributes['on'] .= ','.implode(',', $listeners); } return $attributes; } public static function yoprefix($attr): string { return self::$yoprefixCache[$attr] ??= self::YOYO_PREFIX.':'.$attr; } public static function yoprefix_value($string): string { return self::YOYO_PREFIX.'-'.$string; } public static function hxprefix($attr): string { return self::$hxprefixCache[$attr] ??= self::HTMX_PREFIX.'-'.$attr; } protected function getComponentRootNode($xpath) { $count = 0; foreach ($xpath->query('/html/body/*') as $node) { if ($node->nodeType === XML_ELEMENT_NODE) { $count++; } } return $count == 1 ? $node : false; } protected static function elementHasAttributeWithValue($element, $attr, $value) { if (! $element->hasAttribute($attr)) { return false; } $string = $element->getAttribute($attr); return strpos($string, $value) !== false; } protected function getOuterHTML($dom, $xpath = null): string { $output = ''; if ($xpath === null) { $xpath = new DOMXPath($dom); } $elements = $xpath->query("//*[starts-with(name(@*),'hx-')]"); foreach ($elements as $node) { $setDefaultAction = true; foreach (['get', 'post', 'put', 'delete', 'patch', 'ws', 'sse'] as $verb) { if ($node->hasAttribute('hx-'.$verb)) { $setDefaultAction = false; break; } } if ($setDefaultAction) { $node->setAttribute('hx-get', 'render'); } } foreach ($dom->getElementsByTagName('body')->item(0)->childNodes as $node) { $output .= $dom->saveHTML($node); } return $output; } protected function getInnerHTML($dom, $xpath = null): string { $output = ''; if ($xpath === null) { $xpath = new DOMXPath($dom); } $elements = $xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' ".self::COMPONENT_WRAPPER_CLASS." ')]"); if (! $elements->length) { return $this->getOuterHTML($dom, $xpath); } foreach ($elements->item(0)->childNodes as $node) { $output .= $dom->saveHTML($node); } return $output; } } ================================================ FILE: src/yoyo/YoyoHelpers.php ================================================ $val) { $newKey = is_array($val) ? $key.'[]' : $key; $adjusted[$newKey] = $val; } return json_encode($adjusted, JSON_UNESCAPED_UNICODE | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS); } public static function decode_vals(string $string): array { if (empty($string)) { return []; } return json_decode((string) $string, true); } public static function decode_val(string $string) { $validJson = false; $json = self::test_json($string, $validJson); if ($validJson) { return $json; } return $string === '0' ? 0 : $string; } public static function test_json($string, ?bool &$validJson = null) { $validJson = false; if (is_array($string)) { $validJson = true; return $string; } if (! is_string($string)) { return null; } $decoded = json_decode($string, true); if (json_last_error() === JSON_ERROR_NONE) { $validJson = true; return $decoded; } // Retry after stripping slashes (handles WordPress magic quotes) $unslashed = stripslashes($string); if ($unslashed !== $string) { $decoded = json_decode($unslashed, true); if (json_last_error() === JSON_ERROR_NONE) { $validJson = true; return $decoded; } } return null; } public static function studly($str, $delimiter = ['-', '_']) { $str = str_replace(' ', '', ucwords(str_replace($delimiter, ' ', $str))); return $str; } public static function camel($str, $delimiter = ['-', '_']) { return lcfirst(static::studly($str, $delimiter)); } public static function snake($str, $delimiter = '_') { if (! ctype_lower($str)) { $str = preg_replace('/\\s+/u', '', ucwords($str)); $str = mb_strtolower(preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $str), 'UTF-8'); } return $str; } public static function randString($length = 8) { $characters = '0123456789abcdefghijklmnopqrstuvwxyz'; $charactersLength = strlen($characters); $randomString = ''; for ($i = 0; $i < $length; $i++) { $randomString .= $characters[random_int(0, $charactersLength - 1)]; } return $randomString; } public static function removeEmptyValues(array &$array) { foreach ($array as $key => &$value) { if (is_array($value)) { $value = static::removeEmptyValues($value); } if (is_array($value) && empty($value)) { unset($array[$key]); } if (is_null($value) || (is_string($value) && ! strlen($value))) { unset($array[$key]); } } return $array; } } ================================================ FILE: src/yoyo/YoyoPhalconController.php ================================================ view->disable(); /** @var Yoyo $yoyo */ $yoyo = $this->di->get('yoyo'); $yoyoRequest = new Request(); $yoyoRequest->mock($_REQUEST, $_SERVER); $yoyo->bindRequest($yoyoRequest); $this->response->setContent($yoyo->update()); return $this->response; } } ================================================ FILE: src/yoyo/YoyoPhalconServiceProvider.php ================================================ yoyoConfig = $yoyoConfig; return $this; } public function setViewExtention($viewExtention) { $this->viewExtention = $viewExtention; return $this; } public function register(DiInterface $di): void { $di->setShared('yoyo', function () use ($di) { $yoyo = new Yoyo(); $yoyoConfig = $this->yoyoConfig ?? [ 'url' => '/yoyo', 'namespace' => 'App\Components\\', 'scriptsPath' => 'js/', ]; $yoyo->configure($yoyoConfig); $viewExtention = $this->viewExtention ?? null; $yoyo->container()->set('yoyo.view.default', function () use ($di, $viewExtention) { $view = $di->get('view'); $simpleView = new SimpleView(); $simpleView->setViewsDir($view->getViewsDir()); /** @var PhalconViewProvider $viewProvider */ $viewProvider = new PhalconViewProvider($simpleView); if ($viewExtention) { $viewProvider->setViewExtention($this->viewExtention); } return $viewProvider; }); return $yoyo; }); } } ================================================ FILE: src/yoyo/helpers.php ================================================ mount($name, $variables, $attributes)->render(); } } function yoyo_scripts($return = false) { $output = Configuration::scripts(); if ($return) { return $output; } echo $output; } function yoyo_styles($return = false) { $output = Configuration::styles(); if ($return) { return $output; } echo $output; } function abort($code, $message = '', array $headers = []) { Yoyo::abort($code, $message, $headers); } function abort_if($boolean, $code, $message = '', array $headers = []) { if ($boolean) { Yoyo::abort($code, $message, $headers); } } function abort_unless($boolean, $code, $message = '', array $headers = []) { if (! $boolean) { Yoyo::abort($code, $message, $headers); } } function encode_vals($vals) { echo \Clickfwd\Yoyo\YoyoHelpers::encode_vals($vals); } function is_spinning($expression = null) { $request = Yoyo::request(); if ($request->isYoyoRequest()) { if (! $expression) { return true; } echo $expression; } elseif (! $expression) { return false; } } function not_spinning($expression = null) { $request = Yoyo::request(); if (! $request->isYoyoRequest()) { if (! $expression) { return true; } echo $expression; } elseif (! $expression) { return false; } } ================================================ FILE: tests/Benchmark/PipelineBenchmarkTest.php ================================================ $label, 'iterations' => $iterations, 'total_ms' => $elapsed, 'per_op_ms' => $perOp]; } function createComponentInstance(string $class = Counter::class, string $name = 'counter'): Component { $resolver = new ComponentResolver(Yoyo::getInstance()); return new $class($resolver, 'bench-'.$name, $name); } // --- ClassHelpers Reflection --- test('BENCH: getPublicProperties', function () { $component = createComponentInstance(); $result = benchmark('ClassHelpers::getPublicProperties', 5000, function () use ($component) { ClassHelpers::getPublicProperties($component, Component::class); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: getDefaultPublicVars', function () { $component = createComponentInstance(); $result = benchmark('ClassHelpers::getDefaultPublicVars', 5000, function () use ($component) { ClassHelpers::getDefaultPublicVars($component, Component::class); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: classUsesRecursive', function () { $result = benchmark('ClassHelpers::classUsesRecursive', 5000, function () { ClassHelpers::classUsesRecursive(ComponentWithTrait::class); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: getPublicMethods', function () { $result = benchmark('ClassHelpers::getPublicMethods', 5000, function () { ClassHelpers::getPublicMethods(Counter::class, ['render']); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); // --- Request JSON decoding --- test('BENCH: Request::all() with JSON values', function () { $_REQUEST = [ 'name' => 'test', 'count' => '5', 'data' => '{"key":"value","nested":{"a":1}}', 'list' => '[1,2,3]', 'plain' => 'hello', 'yoyo-id' => 'yoyo-abc123', ]; $request = new Request(); $result = benchmark('Request::all()', 5000, function () use ($request) { $request->all(); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: Request::get() repeated access', function () { $_REQUEST = ['data' => '{"key":"value"}']; $request = new Request(); $result = benchmark('Request::get() x3', 5000, function () use ($request) { $request->get('data'); $request->get('data'); $request->get('data'); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); // --- Full render pipeline --- test('BENCH: full component render (Counter)', function () { $result = benchmark('render(Counter)', 500, function () { render('counter', ['count' => 0]); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: full component update (Counter::increment)', function () { $result = benchmark('update(Counter::increment)', 500, function () { mockYoyoGetRequest('http://localhost/', 'counter/increment'); update('counter', 'increment', ['count' => 0]); resetYoyoRequest(); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); // --- YoyoCompiler --- test('BENCH: YoyoCompiler::compile() simple HTML', function () { $html = '

Hello World

'; $result = benchmark('YoyoCompiler::compile(simple)', 1000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: YoyoCompiler::compile() with yoyo attributes', function () { $html = '
'; $result = benchmark('YoyoCompiler::compile(yoyo+form)', 1000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: YoyoCompiler::compile() with unicode', function () { $html = '

日本語テスト Unicode: äöü ñ

'; $result = benchmark('YoyoCompiler::compile(unicode)', 1000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); // --- Computed properties --- test('BENCH: computed property access', function () { $result = benchmark('render(computed-property)', 500, function () { render('computed-property'); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); ================================================ FILE: tests/Benchmark/RealWorldBenchmarkTest.php ================================================ $label, 'iterations' => $iterations, 'total_ms' => $elapsed, 'per_op_ms' => $perOp]; } // --------------------------------------------------------------------------- // Helper: build a compiled child component (as the parent compiler sees it) // --------------------------------------------------------------------------- function childComponent(string $name, int $id, string $innerHtml, array $vals = []): string { $valsJson = json_encode(array_merge(['yoyo-id' => "{$name}-{$id}"], $vals)); return '
'.$innerHtml.'
'; } // --------------------------------------------------------------------------- // Realistic templates // --------------------------------------------------------------------------- // 1. Simple interactive component — counter with 2 buttons $counterHtml = <<<'HTML'
0
HTML; // 2. Form with validation — registration/contact form $formHtml = <<<'HTML'
Name is required
Invalid email
HTML; // 3. Listing list with nested child components per row (JReviews pattern) // Parent Yoyo component renders the list; each row has 3 child Yoyo // components already compiled: favorite, mylist, comparison function buildListingList(int $rows): string { $html = '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; for ($i = 1; $i <= $rows; $i++) { $html .= '
'; $html .= '
'; $html .= '
'; $html .= '

Business Name '.$i.'

'; $html .= '

Category > Subcategory

'; $html .= '
★★★★☆ (42 reviews)
'; $html .= '

123 Main St, City, ST 12345

'; $html .= '
'; // 3 nested Yoyo child components (already compiled, as the parent sees them) $html .= '
'; $html .= childComponent( 'favorite', $i, '', ['listingId' => $i, 'isFavorite' => 0] ); $html .= childComponent( 'mylist', $i, '3 lists', ['listingId' => $i, 'inList' => 0] ); $html .= childComponent( 'compare', $i, '', ['listingId' => $i, 'inCompare' => 0] ); $html .= '
'; $html .= '
'; } $html .= '
'; // Pagination $html .= ''; $html .= '
'; return $html; } $listingList10 = buildListingList(10); $listingList25 = buildListingList(25); $listingList50 = buildListingList(50); // 4. Listing form — complex form with many interactive fields $listingFormHtml = '
'; $listingFormHtml .= '

Basic Info

'; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= '
'; $listingFormHtml .= '

Location

'; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= '
'; // Custom fields section with various interactive field types $listingFormHtml .= '

Custom Fields

'; for ($f = 1; $f <= 8; $f++) { $listingFormHtml .= '
'; $listingFormHtml .= ''; if ($f % 3 === 0) { $listingFormHtml .= ''; } elseif ($f % 3 === 1) { $listingFormHtml .= ''; } else { $listingFormHtml .= ''; } $listingFormHtml .= '
'; } $listingFormHtml .= '
'; // Media upload section with nested Yoyo component $listingFormHtml .= '

Media

'; $listingFormHtml .= childComponent( 'media-upload', 1, '

Drop files here

' .'
', ['listingId' => 1, 'maxFiles' => 10] ); $listingFormHtml .= '
'; $listingFormHtml .= '
'; $listingFormHtml .= ''; $listingFormHtml .= ''; $listingFormHtml .= '
'; $listingFormHtml .= '
'; // 5. Admin browse page — CP table with status toggles, edit actions per row function buildAdminTable(int $rows): string { $html = '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= '
'; $html .= ''; for ($i = 1; $i <= $rows; $i++) { $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; } $html .= '
IDTitleAuthorCategoryReviewsFeaturedStatusActions
'.$i.'Business '.$i.'
Category > Sub
User '.$i.'
Jan '.($i % 28 + 1).', 2025
Category Name★ 4.'.($i % 10).' ('.$i.' reviews)
'; $html .= '
'; return $html; } $adminTable25 = buildAdminTable(25); $adminTable50 = buildAdminTable(50); // --------------------------------------------------------------------------- // Benchmarks // --------------------------------------------------------------------------- test('BENCH: counter — simple interactive component', function () use ($counterHtml) { $result = bench_run('Counter (2 buttons)', 1000, fn () => compile_html('counter', $counterHtml)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: form — registration with validation', function () use ($formHtml) { $result = bench_run('Form (inputs + submit)', 1000, fn () => compile_html('form', $formHtml)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: listing list — 10 rows × 3 child components', function () use ($listingList10) { $result = bench_run('Listing List (10×3 children)', 200, fn () => compile_html('listing-list', $listingList10)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: listing list — 25 rows × 3 child components', function () use ($listingList25) { $result = bench_run('Listing List (25×3 children)', 200, fn () => compile_html('listing-list', $listingList25)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: listing list — 50 rows × 3 child components', function () use ($listingList50) { $result = bench_run('Listing List (50×3 children)', 100, fn () => compile_html('listing-list', $listingList50)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: listing form — complex form with fields + media upload', function () use ($listingFormHtml) { $result = bench_run('Listing Form (fields + media)', 500, fn () => compile_html('listing-form', $listingFormHtml)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: admin table — 25 rows with toggles + actions', function () use ($adminTable25) { $result = bench_run('Admin Table (25 rows)', 200, fn () => compile_html('browse-listings', $adminTable25)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: admin table — 50 rows with toggles + actions', function () use ($adminTable50) { $result = bench_run('Admin Table (50 rows)', 100, fn () => compile_html('browse-listings', $adminTable50)); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); // --- Summary --- test('BENCH: summary', function () use ($counterHtml, $formHtml, $listingList10, $listingList25, $listingList50, $listingFormHtml, $adminTable25, $adminTable50) { $scenarios = [ ['Counter (2 buttons)', 1000, fn () => compile_html('counter', $counterHtml)], ['Form (inputs + submit)', 1000, fn () => compile_html('form', $formHtml)], ['Listing List (10×3 children)', 200, fn () => compile_html('listing-list', $listingList10)], ['Listing List (25×3 children)', 200, fn () => compile_html('listing-list', $listingList25)], ['Listing List (50×3 children)', 100, fn () => compile_html('listing-list', $listingList50)], ['Listing Form (fields + media)', 500, fn () => compile_html('listing-form', $listingFormHtml)], ['Admin Table (25 rows)', 200, fn () => compile_html('browse-listings', $adminTable25)], ['Admin Table (50 rows)', 100, fn () => compile_html('browse-listings', $adminTable50)], ]; fwrite(STDERR, "\n"); fwrite(STDERR, " ┌──────────────────────────────────────┬────────┬──────────────┐\n"); fwrite(STDERR, " │ Scenario │ ops │ ms/op │\n"); fwrite(STDERR, " ├──────────────────────────────────────┼────────┼──────────────┤\n"); foreach ($scenarios as [$label, $iterations, $fn]) { $r = bench_run($label, $iterations, $fn); fwrite(STDERR, sprintf( " │ %-36s │ %5d │ %10.4f │\n", $label, $r['iterations'], $r['per_op_ms'] )); } fwrite(STDERR, " └──────────────────────────────────────┴────────┴──────────────┘\n\n"); expect(true)->toBeTrue(); })->group('benchmark'); ================================================ FILE: tests/Benchmark/YoyoCompilerBenchmarkTest.php ================================================ $label, 'iterations' => $iterations, 'total_ms' => $elapsed, 'per_op_ms' => $perOp]; } // --- Phase breakdown: isolate individual steps of compile() --- test('BENCH: preg_replace yoyo prefix finder', function () { $html = '
'; $prefix = YoyoCompiler::YOYO_PREFIX; $finder = YoyoCompiler::YOYO_PREFIX_FINDER; $result = bench('preg_replace (prefix finder)', 5000, function () use ($html, $prefix, $finder) { preg_replace('/ '.$prefix.':(.*)="(.*)"/U', " $finder $prefix:\$1=\"\$2\"", $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: mb_encode_numericentity (ASCII only)', function () { $html = '

Hello World

'; $result = bench('mb_encode_numericentity (ASCII)', 5000, function () use ($html) { mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, ~0], 'UTF-8'); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: mb_encode_numericentity (unicode)', function () { $html = '

极简、极速、极致 海豚PHP áéíóü café naïve

'; $result = bench('mb_encode_numericentity (unicode)', 5000, function () use ($html) { mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, ~0], 'UTF-8'); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: DOMDocument loadHTML', function () { $html = '

Hello

'; $result = bench('DOMDocument::loadHTML', 5000, function () use ($html) { $dom = new DOMDocument(); $internalErrors = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_use_internal_errors($internalErrors); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: DOMDocument loadHTML + saveHTML', function () { $html = '

Hello

'; $result = bench('DOMDocument load+save', 5000, function () use ($html) { $dom = new DOMDocument(); $internalErrors = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_use_internal_errors($internalErrors); foreach ($dom->getElementsByTagName('body')->item(0)->childNodes as $node) { $dom->saveHTML($node); } }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: DOMXPath query', function () { $dom = new DOMDocument(); $dom->loadHTML('
'); $xpath = new DOMXPath($dom); $result = bench('DOMXPath::query', 5000, function () use ($xpath) { $xpath->query('//form'); $xpath->query('//*[@yoyo]|//*[@yoyo-finder]'); $xpath->query('/html/body/*'); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); // --- Full compile at different complexity levels --- test('BENCH: compile() minimal HTML', function () { $html = '
Hello
'; $result = bench('compile(minimal)', 2000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() with 5 yoyo children', function () { $html = '
'; for ($i = 0; $i < 5; $i++) { $html .= ''; } $html .= '
'; $result = bench('compile(5 yoyo children)', 1000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() with 20 yoyo children', function () { $html = '
'; for ($i = 0; $i < 20; $i++) { $html .= ''; } $html .= '
'; $result = bench('compile(20 yoyo children)', 500, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() with form + file inputs', function () { $html = '
'; $result = bench('compile(form+file)', 1000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() with yoyo:vals JSON', function () { $html = '

Content

'; $result = bench('compile(yoyo:vals JSON)', 1000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() with multiple yoyo:val attributes', function () { $html = '

Content

'; $result = bench('compile(4x yoyo:val attrs)', 1000, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() realistic component (todo-list sized)', function () { $html = '
'; $html .= '
'; $html .= ''; $html .= '
All | Active | Done
'; $html .= '
'; $result = bench('compile(realistic todo-list)', 500, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() large table (50 rows)', function () { $html = '
'; for ($i = 0; $i < 50; $i++) { $html .= ''; $html .= ''; } $html .= '
IDNameAction
'.$i.'Item '.$i.''; $html .= '
'; $result = bench('compile(50-row table)', 200, function () use ($html) { compile_html('test', $html); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); test('BENCH: compile() innerHTML swap (spinning)', function () { $html = '

Content 1

Content 2

Content 3

'; $result = bench('compile(innerHTML swap, spinning)', 1000, function () use ($html) { compile_html('test', $html, $spinning = true); }); expect($result['total_ms'])->toBeGreaterThan(0); })->group('benchmark'); // --- Summary --- test('BENCH: compile cost breakdown summary', function () { fwrite(STDERR, "\n --- Compile Cost Breakdown ---\n"); // Minimal $html = '
Hello
'; $simple = bench(' SUMMARY: minimal div', 2000, function () use ($html) { compile_html('test', $html); }); // With prefix regex $html = '
'; $attrs = bench(' SUMMARY: with yoyo: attrs', 2000, function () use ($html) { compile_html('test', $html); }); // With vals $html = '

Content

'; $vals = bench(' SUMMARY: with yoyo:val attrs', 2000, function () use ($html) { compile_html('test', $html); }); fwrite(STDERR, sprintf( "\n Cost of yoyo attrs: +%.4fms/op (%.0f%% overhead)\n", $attrs['per_op_ms'] - $simple['per_op_ms'], (($attrs['per_op_ms'] / $simple['per_op_ms']) - 1) * 100 )); fwrite(STDERR, sprintf( " Cost of val parsing: +%.4fms/op (%.0f%% overhead vs minimal)\n\n", $vals['per_op_ms'] - $simple['per_op_ms'], (($vals['per_op_ms'] / $simple['per_op_ms']) - 1) * 100 )); expect($simple['per_op_ms'])->toBeLessThan(1.0); })->group('benchmark'); ================================================ FILE: tests/Benchmark/profile-compiler.php ================================================ $label, 'iterations' => $iterations, 'total_ms' => $elapsed, 'per_op_ms' => $elapsed / $iterations, ]; } function buildHtml(int $rows): string { $html = '
'; for ($i = 0; $i < $rows; $i++) { $html .= ''; $html .= ''; } $html .= '
' . $i . 'Item ' . $i . ''; $html .= '
'; return $html; } function buildTodoHtml(): string { $html = '
'; $html .= '
'; $html .= ''; $html .= '
All | Active | Done
'; $html .= '
'; return $html; } // ─── Phase profiling for a given HTML ────────────────────────────── function profilePhases(string $html, int $iters): array { $prefix = 'yoyo'; $finder = 'yoyo-finder'; // Phase 1: Regex (yoyo-finder injection) $phase1 = bench('Regex: yoyo-finder injection', $iters, function () use ($html, $prefix, $finder) { preg_replace( ['/ ' . $prefix . ':(.*)="(.*)"/U', '/ ' . $prefix . ':(.*)=\'(.*)\'/U'], [" $finder $prefix:\$1=\"\$2\"", " $finder $prefix:\$1='\$2'"], $html ); }); // Prepare HTML after regex $regexed = preg_replace( ['/ ' . $prefix . ':(.*)="(.*)"/U', '/ ' . $prefix . ':(.*)=\'(.*)\'/U'], [" $finder $prefix:\$1=\"\$2\"", " $finder $prefix:\$1='\$2'"], $html ); // Phase 2: DOM parse $phase2 = bench('DOM: loadHTML', $iters, function () use ($regexed) { $dom = new DOMDocument(); $e = libxml_use_internal_errors(true); $dom->loadHTML($regexed); libxml_use_internal_errors($e); }); // Phase 3: XPath creation + queries $dom = new DOMDocument(); libxml_use_internal_errors(true); $dom->loadHTML($regexed); libxml_use_internal_errors(false); $phase3 = bench('XPath: create + 3 queries', $iters, function () use ($dom) { $xpath = new DOMXPath($dom); $xpath->query('/html/body/*'); $xpath->query('//form'); $xpath->query('//*[@yoyo]|//*[@yoyo-finder]'); }); // Count reactive elements $xpath = new DOMXPath($dom); $children = $xpath->query('//*[@yoyo]|//*[@yoyo-finder]'); $childCount = max(0, $children->length - 1); // Phase 4: Per-element attribute scanning (3 passes currently) $phase4 = bench('Children: attr scanning (3-pass)', $iters, function () use ($children) { foreach ($children as $key => $el) { if ($key == 0) { continue; } // Pass 1: addRequestMethodAttribute - check hx-* (8 checks) foreach (['boost', 'delete', 'get', 'patch', 'post', 'put', 'sse', 'ws'] as $m) { $el->hasAttribute('hx-' . $m); } // Pass 1b: addRequestMethodAttribute - check yoyo:* (8 checks) foreach (['boost', 'delete', 'get', 'patch', 'post', 'put', 'sse', 'ws'] as $m) { $el->getAttribute('yoyo:' . $m); } // Pass 2: scan for yoyo: attributes foreach ($el->attributes as $a) { str_starts_with($a->name, 'yoyo:'); } // Pass 3: scan for yoyo:val. attributes foreach ($el->attributes as $a) { str_starts_with($a->name, 'yoyo:val.'); } } }); // Phase 5: DOM mutations (setAttribute/removeAttribute per element) $phase5 = bench('Children: DOM mutations (set/remove)', $iters, function () use ($children) { foreach ($children as $key => $el) { if ($key == 0) { continue; } // Typical: remove yoyo:post, set hx-post, remove yoyo:val.id, set hx-vals $el->setAttribute('hx-post', 'edit'); $el->removeAttribute('hx-post'); $el->setAttribute('hx-vals', '{"id":1}'); $el->removeAttribute('hx-vals'); } }); // Phase 6: encode/decode vals $phase6 = bench('Vals: encode + decode per element', $iters, function () use ($childCount) { for ($i = 0; $i < $childCount; $i++) { Clickfwd\Yoyo\YoyoHelpers::decode_val((string) $i); Clickfwd\Yoyo\YoyoHelpers::camel('sort-field', '-'); Clickfwd\Yoyo\YoyoHelpers::encode_vals(['id' => $i]); } }); // Phase 7: getOuterHTML (extra XPath + method check) $phase7 = bench('Output: getOuterHTML XPath + check', $iters, function () use ($dom) { $xpath2 = new DOMXPath($dom); $matched = $xpath2->query("//*[starts-with(name(@*),'hx-')]"); foreach ($matched as $n) { foreach (['get', 'post', 'put', 'delete', 'patch', 'ws', 'sse'] as $v) { if ($n->hasAttribute('hx-' . $v)) { break; } } } }); // Phase 8: saveHTML serialization $phase8 = bench('Output: saveHTML (serialize)', $iters, function () use ($dom) { $output = ''; foreach ($dom->getElementsByTagName('body')->item(0)->childNodes as $n) { $output .= $dom->saveHTML($n); } }); // Full compile $full = bench('FULL: compile()', $iters, function () use ($html) { Tests\compile_html('test', $html); }); return [ 'child_count' => $childCount, 'full' => $full, 'phases' => [$phase1, $phase2, $phase3, $phase4, $phase5, $phase6, $phase7, $phase8], ]; } // ─── Scaling analysis ────────────────────────────────────────────── function profileScaling(): array { $sizes = [0, 1, 5, 10, 20, 50]; $results = []; foreach ($sizes as $rows) { $html = $rows === 0 ? '
Hello
' : buildHtml($rows); $iters = $rows <= 5 ? 2000 : ($rows <= 20 ? 1000 : 500); $r = bench("$rows rows", $iters, function () use ($html) { Tests\compile_html('test', $html); }); $r['rows'] = $rows; $r['reactive_elements'] = $rows * 2; $results[] = $r; } return $results; } // ─── Run everything ──────────────────────────────────────────────── fwrite(STDERR, "Profiling 50-row table...\n"); $table50 = profilePhases(buildHtml(50), 500); fwrite(STDERR, "Profiling realistic todo-list...\n"); $todoList = profilePhases(buildTodoHtml(), 1000); fwrite(STDERR, "Profiling minimal component...\n"); $minimal = profilePhases('
Hello
', 2000); fwrite(STDERR, "Profiling scaling behavior...\n"); $scaling = profileScaling(); // ─── Generate HTML report ────────────────────────────────────────── $phaseColors = [ '#e74c3c', // 1 regex - red '#3498db', // 2 DOM parse - blue '#2ecc71', // 3 XPath - green '#f39c12', // 4 attr scan - orange (HOT) '#9b59b6', // 5 DOM mutations - purple '#1abc9c', // 6 vals encode - teal '#e67e22', // 7 getOuterHTML - dark orange '#95a5a6', // 8 saveHTML - gray ]; function phaseBar(array $profile, array $colors): string { $full = $profile['full']['per_op_ms']; $sum = 0; $segments = []; foreach ($profile['phases'] as $i => $phase) { $pct = ($phase['per_op_ms'] / $full) * 100; $sum += $phase['per_op_ms']; $segments[] = sprintf( '
', $pct, $colors[$i], htmlspecialchars($phase['label']), $phase['per_op_ms'], $pct ); } $unaccounted = $full - $sum; $uPct = ($unaccounted / $full) * 100; $segments[] = sprintf( '
', $uPct, $unaccounted, $uPct ); return implode('', $segments); } function phaseTable(array $profile): string { global $phaseColors; $full = $profile['full']['per_op_ms']; $rows = ''; $sum = 0; foreach ($profile['phases'] as $i => $phase) { $pct = ($phase['per_op_ms'] / $full) * 100; $sum += $phase['per_op_ms']; $bar = str_repeat('█', max(1, (int) round($pct / 2))); $rows .= sprintf( '%s%.4f%.1f%%
', $phaseColors[$i], htmlspecialchars($phase['label']), $phase['per_op_ms'], $pct, $pct, $phaseColors[$i] ); } $unaccounted = $full - $sum; $uPct = ($unaccounted / $full) * 100; $rows .= sprintf( 'Other (root attrs, form, overhead)%.4f%.1f%%
', $unaccounted, $uPct, $uPct ); $rows .= sprintf( 'Total compile()%.4f100%%', $full ); return $rows; } function scalingChart(array $scaling): string { $maxMs = 0; foreach ($scaling as $r) { $maxMs = max($maxMs, $r['per_op_ms']); } $bars = ''; foreach ($scaling as $r) { $pct = ($r['per_op_ms'] / $maxMs) * 100; $label = $r['rows'] === 0 ? 'minimal' : $r['rows'] . ' rows'; $bars .= sprintf( '
%s
%d elems
%.3fms
', $label, $r['reactive_elements'], $pct, $r['per_op_ms'] ); } return $bars; } $html = << YoyoCompiler Performance Profile

YoyoCompiler — Performance Profile

PHP {PHP_VERSION} · {$table50['full']['iterations']} iterations · {$table50['child_count']} reactive elements in 50-row table

1. Phase Breakdown — 50-Row Table ({$table50['child_count']} reactive elements)

Time Distribution (hover for details)

{PHASE_BAR_TABLE50}
{PHASE_TABLE_TABLE50}
Phasems/op%Distribution
Key finding: Attribute scanning (3 separate passes per element) is the #1 scaling cost at {SCAN_PCT}%. Combined with the redundant XPath in getOuterHTML ({OUTER_PCT}%), these two account for {COMBINED_PCT}% of compile time — and both are optimizable.

2. Phase Breakdown Comparison

Realistic Todo-List ({$todoList['child_count']} elements)

{PHASE_BAR_TODO}
{PHASE_TABLE_TODO}
Phasems/op%

Minimal Component (0 elements)

{PHASE_BAR_MINIMAL}
{PHASE_TABLE_MINIMAL}
Phasems/op%

3. Scaling: Compile Time vs Component Size

{SCALING_CHART}
Scaling is linear with reactive element count. Each element adds ~{PER_ELEM_COST}ms. The fixed overhead (regex + DOM parse + XPath + serialize) is ~{FIXED_COST}ms regardless of size.

4. Optimization Opportunities

OptimizationTargetExpected Impact
Single-pass attribute scan Children: attr scanning ({SCAN_PCT}%) Eliminate 2 of 3 passes → ~{SCAN_SAVE}% total savings
Eliminate redundant XPath in getOuterHTML Output: getOuterHTML ({OUTER_PCT}%) Reuse existing XPath → ~{OUTER_SAVE}% savings
Inline addRequestMethodAttribute for children Part of attr scanning Avoid 16 DOM calls per element → included in single-pass
PHP 8.4 Dom\HTMLDocument DOM: loadHTML ({PARSE_PCT}%) Actually ~20% slower for small/medium HTML

Generated by profile-compiler.php

HTML; // Fill in placeholders $scanPct = sprintf('%.1f', ($table50['phases'][3]['per_op_ms'] / $table50['full']['per_op_ms']) * 100); $outerPct = sprintf('%.1f', ($table50['phases'][6]['per_op_ms'] / $table50['full']['per_op_ms']) * 100); $combinedPct = sprintf('%.1f', (float)$scanPct + (float)$outerPct); $parsePct = sprintf('%.1f', ($table50['phases'][1]['per_op_ms'] / $table50['full']['per_op_ms']) * 100); // Per-element cost = (50-row total - minimal total) / 100 elements $perElemCost = sprintf('%.4f', ($scaling[5]['per_op_ms'] - $scaling[0]['per_op_ms']) / 100); $fixedCost = sprintf('%.3f', $scaling[0]['per_op_ms']); $replacements = [ '{PHASE_BAR_TABLE50}' => phaseBar($table50, $phaseColors), '{PHASE_TABLE_TABLE50}' => phaseTable($table50), '{PHASE_BAR_TODO}' => phaseBar($todoList, $phaseColors), '{PHASE_TABLE_TODO}' => phaseTable($todoList), '{PHASE_BAR_MINIMAL}' => phaseBar($minimal, $phaseColors), '{PHASE_TABLE_MINIMAL}' => phaseTable($minimal), '{SCALING_CHART}' => scalingChart($scaling), '{SCAN_PCT}' => $scanPct, '{OUTER_PCT}' => $outerPct, '{COMBINED_PCT}' => $combinedPct, '{PARSE_PCT}' => $parsePct, '{SCAN_SAVE}' => sprintf('%.0f', (float)$scanPct * 0.7), '{OUTER_SAVE}' => $outerPct, '{PER_ELEM_COST}' => $perElemCost, '{FIXED_COST}' => $fixedCost, ]; $html = str_replace(array_keys($replacements), array_values($replacements), $html); $outputPath = __DIR__ . '/profile-report.html'; file_put_contents($outputPath, $html); fwrite(STDERR, "\nReport saved to: $outputPath\n"); ================================================ FILE: tests/Browser/BrowserServer.php ================================================ */ private array $pipes = []; public const HOST = 'localhost'; public const PORT = 8765; private function __construct() { } public static function url(): string { return sprintf('http://%s:%d', self::HOST, self::PORT); } /** * Ensure the server is running. Safe to call multiple times. */ public static function ensureRunning(): void { if (self::$instance !== null) { return; } self::$instance = new self(); // Already running externally (manual start)? if (self::isListening()) { return; } self::$instance->start(); register_shutdown_function([self::class, 'stop']); } /** * Start the PHP built-in server. */ private function start(): void { $router = realpath(__DIR__.'/server/index.php'); if ($router === false) { throw new \RuntimeException('Browser test server router not found at tests/Browser/server/index.php'); } $cmd = sprintf( 'php -S %s:%d %s', self::HOST, self::PORT, escapeshellarg($router) ); $this->process = proc_open( $cmd, [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], $this->pipes, dirname($router) ); if (! is_resource($this->process)) { throw new \RuntimeException('Failed to start browser test server'); } // Wait for server to accept connections (up to 5 seconds) for ($i = 0; $i < 50; $i++) { if (self::isListening()) { return; } usleep(100_000); // 100ms } self::stop(); throw new \RuntimeException( sprintf('Browser test server failed to start on %s:%d within 5 seconds', self::HOST, self::PORT) ); } /** * Check if the server port is accepting connections. */ public static function isListening(): bool { $sock = @fsockopen(self::HOST, self::PORT, $errno, $errstr, 0.5); if ($sock) { fclose($sock); return true; } return false; } /** * Stop the server if we started it. */ public static function stop(): void { if (self::$instance === null) { return; } $instance = self::$instance; if (is_resource($instance->process)) { // Close stdin to signal shutdown if (isset($instance->pipes[0]) && is_resource($instance->pipes[0])) { fclose($instance->pipes[0]); } proc_terminate($instance->process); proc_close($instance->process); $instance->process = null; } self::$instance = null; } } ================================================ FILE: tests/Browser/Components/ActionButton.php ================================================ emit('notification'); } } ================================================ FILE: tests/Browser/Components/Counter.php ================================================ count++; } public function decrement() { $this->count--; } } ================================================ FILE: tests/Browser/Components/DeleteItem.php ================================================ skipRender(); } } ================================================ FILE: tests/Browser/Components/DispatchBystander.php ================================================ 'handlePostCreated', 'status-changed' => 'handleStatusChanged', 'simple-refresh' => 'handleSimpleRefresh', ]; public function handlePostCreated($postId) { $this->message = "Post created with ID: {$postId}"; } public function handleStatusChanged($status, $reason = 'none') { $this->message = "Status: {$status}, Reason: {$reason}"; } public function handleSimpleRefresh() { $this->message = 'Refreshed without params'; } } ================================================ FILE: tests/Browser/Components/FavoriteButton.php ================================================ isFavorited = $this->isFavorited ? 0 : 1; } } ================================================ FILE: tests/Browser/Components/Form.php ================================================ errors = []; if (empty($this->name)) { $this->errors['name'] = 'Name is required.'; } if (empty($this->email)) { $this->errors['email'] = 'Email is required.'; } if (empty($this->errors)) { $this->success = true; } } } ================================================ FILE: tests/Browser/Components/LiveSearch.php ================================================ 'PHP Basics'], ['title' => 'PHP Advanced'], ['title' => 'JavaScript Guide'], ['title' => 'CSS Flexbox'], ['title' => 'HTML Forms'], ['title' => 'Laravel Framework'], ['title' => 'Yoyo Components'], ['title' => 'Alpine JS'], ]; protected function getResultsProperty() { if (! $this->q) { return []; } return array_filter(self::$data, function ($item) { return stripos($item['title'], $this->q) !== false; }); } } ================================================ FILE: tests/Browser/Components/ModalTrigger.php ================================================ isOpen = 1; } public function closeModal() { $this->isOpen = 0; } } ================================================ FILE: tests/Browser/Components/MultiScreen.php ================================================ 'onNotification']; public function onNotification() { $this->count = $this->count + 1; } } ================================================ FILE: tests/Browser/Components/NullProp.php ================================================ clicks++; } } ================================================ FILE: tests/Browser/Components/Pagination.php ================================================ totalItems; $i++) { $items[] = ['title' => "Item $i"]; } $offset = ($this->page - 1) * $this->perPage; return array_slice($items, $offset, $this->perPage); } protected function getTotalPagesProperty() { return (int) ceil($this->totalItems / $this->perPage); } protected function getStartProperty() { return (($this->page - 1) * $this->perPage) + 1; } protected function getEndProperty() { return min($this->page * $this->perPage, $this->totalItems); } } ================================================ FILE: tests/Browser/Components/ResponseHeaders.php ================================================ response->retarget('#retarget-receiver'); $this->message = 'retargeted content'; } public function doReswap() { $this->response->reswap('innerHTML'); $this->message = 'inner-swapped'; } public function doTrigger() { $this->response->trigger('custom-event'); $this->message = 'triggered'; } public function doTriggerAfterSettle() { $this->response->triggerAfterSettle('settle-event'); $this->message = 'settled'; } public function doPushUrl() { $this->response->pushUrl('/pushed-path'); $this->message = 'url-pushed'; } public function doReplaceUrl() { $this->response->replaceUrl('/replaced-path'); $this->message = 'url-replaced'; } } ================================================ FILE: tests/Browser/Components/StatusDropdown.php ================================================ isOpen = ! $this->isOpen; } public function setStatus() { $this->status = $this->newStatus; $this->isOpen = false; } } ================================================ FILE: tests/Browser/Components/TodoList.php ================================================ 1, 'title' => 'Build a framework', 'completed' => false], ['id' => 2, 'title' => 'Buy groceries', 'completed' => false], ['id' => 3, 'title' => 'Write tests', 'completed' => true], ]; } } public function add() { $title = trim($this->request->get('task', '')); if ($title) { $id = max(array_column($_SESSION['todos'], 'id')) + 1; $_SESSION['todos'][] = ['id' => $id, 'title' => $title, 'completed' => false]; } } public function toggle() { $id = (int) $this->request->get('id', 0); foreach ($_SESSION['todos'] as &$todo) { if ($todo['id'] === $id) { $todo['completed'] = ! $todo['completed']; break; } } } public function delete() { $id = (int) $this->request->get('id', 0); $_SESSION['todos'] = array_values(array_filter($_SESSION['todos'], function ($todo) use ($id) { return $todo['id'] !== $id; })); } protected function getEntriesProperty() { $todos = $_SESSION['todos'] ?? []; if ($this->filter === 'active') { return array_filter($todos, fn ($t) => ! $t['completed']); } if ($this->filter === 'completed') { return array_filter($todos, fn ($t) => $t['completed']); } return $todos; } protected function getCountProperty() { return count($_SESSION['todos'] ?? []); } protected function getActiveCountProperty() { return count(array_filter($_SESSION['todos'] ?? [], fn ($t) => ! $t['completed'])); } } ================================================ FILE: tests/Browser/CounterTest.php ================================================ visit(BASE_URL.'/counter') ->assertVisible('#counter') ->assertSeeIn('[data-count]', '0'); }); it('increments count when clicking +', function () { $this->visit(BASE_URL.'/counter') ->assertSeeIn('[data-count]', '0') ->click('[data-action="increment"]') ->assertSeeIn('[data-count]', '1'); }); it('decrements count when clicking -', function () { $this->visit(BASE_URL.'/counter') ->click('[data-action="decrement"]') ->assertSeeIn('[data-count]', '-1'); }); it('maintains state across multiple actions', function () { $page = $this->visit(BASE_URL.'/counter'); for ($i = 0; $i < 3; $i++) { $page->click('[data-action="increment"]') ->assertSeeIn('[data-count]', (string) ($i + 1)) ->wait(0.3); } }); it('updates query string with count value', function () { $this->visit(BASE_URL.'/counter') ->click('[data-action="increment"]') ->assertQueryStringHas('count', '1'); }); ================================================ FILE: tests/Browser/CrossComponentEventsTest.php ================================================ visit(BASE_URL.'/events') ->assertVisible('#events-test') ->assertVisible('#badge') ->assertSeeIn('#badge [data-count]', '0') ->assertVisible('#btn-email') ->assertVisible('#btn-add'); }); it('increments badge count when action button emits event', function () { $page = $this->visit(BASE_URL.'/events') ->assertSeeIn('#badge [data-count]', '0'); $page->click('#btn-email [data-action="fire"]') ->assertSeeIn('#badge [data-count]', '1'); }); it('increments badge count from multiple buttons', function () { $page = $this->visit(BASE_URL.'/events') ->assertSeeIn('#badge [data-count]', '0'); $page->click('#btn-email [data-action="fire"]') ->assertSeeIn('#badge [data-count]', '1') ->wait(0.3); $page->click('#btn-add [data-action="fire"]') ->assertSeeIn('#badge [data-count]', '2'); }); it('badge has notification listener in hx-trigger', function () { $this->visit(BASE_URL.'/events') ->assertSourceHas('hx-trigger="refresh,notification"'); }); ================================================ FILE: tests/Browser/DispatchTest.php ================================================ visit(BASE_URL.'/dispatch') ->assertSourceHas('yoyo:name="dispatch-listener"') ->assertSourceHas('hx-trigger="refresh,post-created,status-changed,simple-refresh"'); }); it('dispatches event with single named param', function () { $this->visit(BASE_URL.'/dispatch') ->click('#btn-dispatch') ->assertSeeIn('#message', 'Post created with ID: 42'); }); it('dispatches event with multiple named params', function () { $this->visit(BASE_URL.'/dispatch') ->click('#btn-dispatch-multi') ->assertSeeIn('#message', 'Status: active, Reason: manual'); }); it('dispatches targeted event via dispatchTo', function () { $this->visit(BASE_URL.'/dispatch') ->click('#btn-dispatch-to') ->assertSeeIn('#message', 'Post created with ID: 7'); }); it('dispatches event without params', function () { $this->visit(BASE_URL.'/dispatch') ->click('#btn-dispatch-no-params') ->assertSeeIn('#message', 'Refreshed without params'); }); it('does not update non-listening bystander component', function () { $this->visit(BASE_URL.'/dispatch') ->assertSeeIn('#bystander', 'unchanged') ->click('#btn-dispatch') ->assertSeeIn('#message', 'Post created with ID: 42') ->assertSeeIn('#bystander', 'unchanged'); }); it('dispatchTo non-existent component is a silent no-op', function () { $page = $this->visit(BASE_URL.'/dispatch'); $page->click('#btn-dispatch-nonexistent'); $page->wait(1); // Listener should still have empty message — component was not updated $page->assertSourceHas(''); }); ================================================ FILE: tests/Browser/FormTest.php ================================================ visit(BASE_URL.'/form') ->assertVisible('#form') ->assertVisible('input#name') ->assertVisible('input#email') ->assertVisible('button[type="submit"]'); }); it('shows validation errors when submitting empty form', function () { $this->visit(BASE_URL.'/form') ->click('button[type="submit"]') ->assertVisible('[data-error="name"]') ->assertSee('Name is required') ->assertVisible('[data-error="email"]') ->assertSee('Email is required'); }); it('shows success message after valid submission', function () { $this->visit(BASE_URL.'/form') ->fill('input#name', 'John Doe') ->fill('input#email', 'john@example.com') ->click('button[type="submit"]') ->assertVisible('[data-success]') ->assertSee('Thank you for registering!'); }); it('replaces form with success state', function () { $this->visit(BASE_URL.'/form') ->fill('input#name', 'Jane') ->fill('input#email', 'jane@test.com') ->click('button[type="submit"]') ->assertMissing('input#name') ->assertMissing('button[type="submit"]') ->assertSee('Thank you for registering!'); }); ================================================ FILE: tests/Browser/InfrastructureTest.php ================================================ visit(BASE_URL.'/counter') ->assertSourceHas('htmx'); }); it('includes yoyo.js script', function () { $this->visit(BASE_URL.'/counter') ->assertSourceHas('yoyo.js'); }); it('initializes Yoyo configuration in JavaScript', function () { $this->visit(BASE_URL.'/counter') ->assertSourceHas('Yoyo.url') ->assertSourceHas('Yoyo.config('); }); it('includes yoyo spinning CSS', function () { $this->visit(BASE_URL.'/counter') ->assertSourceHas('yoyo\\:spinning'); }); it('compiles yoyo: attributes to hx- attributes', function () { $this->visit(BASE_URL.'/counter') ->assertSourceHas('hx-get="increment"') ->assertSourceHas('hx-get="decrement"'); }); it('adds yoyo wrapper attributes to component root', function () { $this->visit(BASE_URL.'/counter') ->assertSourceHas('yoyo:name="counter"') ->assertSourceHas('hx-ext="yoyo"') ->assertSourceHas('hx-include="this"'); }); ================================================ FILE: tests/Browser/LiveSearchTest.php ================================================ visit(BASE_URL.'/live-search') ->assertVisible('#live-search') ->assertVisible('input[name="q"]') ->assertMissing('[data-results]'); }); it('shows matching results when typing', function () { $this->visit(BASE_URL.'/live-search') ->typeSlowly('input[name="q"]', 'php', 100) ->assertVisible('[data-results]') ->assertSeeIn('[data-results]', 'PHP Basics') ->assertSeeIn('[data-results]', 'PHP Advanced'); }); it('shows no results message for unmatched query', function () { $this->visit(BASE_URL.'/live-search') ->typeSlowly('input[name="q"]', 'zzzzz', 100) ->assertVisible('[data-no-results]') ->assertSee('No results found'); }); it('filters results based on query', function () { $this->visit(BASE_URL.'/live-search') ->typeSlowly('input[name="q"]', 'yoyo', 100) ->assertSeeIn('[data-results]', 'Yoyo Components') ->assertDontSeeIn('[data-results]', 'PHP Basics'); }); ================================================ FILE: tests/Browser/ModalTest.php ================================================ visit(BASE_URL.'/modal') ->assertVisible('#modal-trigger') ->assertVisible('[data-action="open"]') ->assertNotPresent('[data-modal]'); }); it('opens modal on button click', function () { $this->visit(BASE_URL.'/modal') ->assertNotPresent('[data-modal]') ->click('[data-action="open"]') ->assertVisible('[data-modal]') ->assertSeeIn('[data-modal-title]', 'Modal Content') ->assertSee('This is the modal body.'); }); it('closes modal on close button click', function () { $page = $this->visit(BASE_URL.'/modal'); $page->click('[data-action="open"]') ->assertVisible('[data-modal]') ->wait(0.3); $page->click('[data-action="close"]') ->assertNotPresent('[data-modal]'); }); it('can reopen modal after closing', function () { $page = $this->visit(BASE_URL.'/modal'); $page->click('[data-action="open"]') ->assertVisible('[data-modal]') ->wait(0.3); $page->click('[data-action="close"]') ->assertNotPresent('[data-modal]') ->wait(0.3); $page->click('[data-action="open"]') ->assertVisible('[data-modal]'); }); it('uses native dialog element', function () { $this->visit(BASE_URL.'/modal') ->click('[data-action="open"]') ->assertSourceHas('assertSourceHas('data-modal'); }); ================================================ FILE: tests/Browser/MultiScreenTest.php ================================================ visit(BASE_URL.'/multi-screen') ->assertVisible('#wizard') ->assertVisible('[data-screen="initial"]') ->assertSeeIn('[data-info]', 'Ready to begin') ->assertVisible('[data-action="open"]'); }); it('transitions to form screen on open', function () { $this->visit(BASE_URL.'/multi-screen') ->assertVisible('[data-screen="initial"]') ->click('[data-action="open"]') ->assertVisible('[data-screen="form"]') ->assertMissing('[data-screen="initial"]') ->assertVisible('input[name="message"]') ->assertVisible('[data-action="submit"]') ->assertVisible('[data-action="cancel"]'); }); it('transitions to success screen on submit', function () { $page = $this->visit(BASE_URL.'/multi-screen'); $page->click('[data-action="open"]') ->assertVisible('[data-screen="form"]') ->wait(0.3); $page->fill('input[name="message"]', 'Hello World') ->click('[data-action="submit"]') ->assertVisible('[data-screen="success"]') ->assertMissing('[data-screen="form"]') ->assertSeeIn('[data-result]', 'Submitted: Hello World'); }); it('returns to initial screen on cancel', function () { $page = $this->visit(BASE_URL.'/multi-screen'); $page->click('[data-action="open"]') ->assertVisible('[data-screen="form"]') ->wait(0.3); $page->click('[data-action="cancel"]') ->assertVisible('[data-screen="initial"]') ->assertMissing('[data-screen="form"]'); }); it('returns to initial screen from success via reset', function () { $page = $this->visit(BASE_URL.'/multi-screen'); $page->click('[data-action="open"]') ->assertVisible('[data-screen="form"]') ->wait(0.3); $page->fill('input[name="message"]', 'Test') ->click('[data-action="submit"]') ->assertVisible('[data-screen="success"]') ->wait(0.3); $page->click('[data-action="reset"]') ->assertVisible('[data-screen="initial"]') ->assertMissing('[data-screen="success"]'); }); ================================================ FILE: tests/Browser/NullPropTest.php ================================================ visit(BASE_URL.'/null-prop') ->assertVisible('#null-prop') ->assertAttribute('[data-icon-slot-type]', 'data-icon-slot-type', 'NULL') ->assertSeeIn('[data-icon-slot-display]', 'NULL_OK') ->click('[data-action="increment"]') ->assertSeeIn('[data-clicks]', '1') ->assertAttribute('[data-icon-slot-type]', 'data-icon-slot-type', 'NULL') ->assertSeeIn('[data-icon-slot-display]', 'NULL_OK'); }); it('preserves PHP false prop after request roundtrip', function () { $this->visit(BASE_URL.'/null-prop') ->assertAttribute('[data-enabled-type]', 'data-enabled-type', 'boolean') ->assertSeeIn('[data-enabled-display]', 'FALSE_OK') ->click('[data-action="increment"]') ->assertSeeIn('[data-clicks]', '1') ->assertAttribute('[data-enabled-type]', 'data-enabled-type', 'boolean') ->assertSeeIn('[data-enabled-display]', 'FALSE_OK'); }); it('emits JSON null and false in button hx-vals', function () { $this->visit(BASE_URL.'/null-prop') ->assertAttributeContains('[data-action="increment"]', 'hx-vals', '"iconSlot":null') ->assertAttributeContains('[data-action="increment"]', 'hx-vals', '"enabled":false') ->assertAttributeDoesntContain('[data-action="increment"]', 'hx-vals', '"iconSlot":"null"') ->assertAttributeDoesntContain('[data-action="increment"]', 'hx-vals', '"enabled":"false"'); }); ================================================ FILE: tests/Browser/PaginationTest.php ================================================ visit(BASE_URL.'/pagination') ->assertVisible('#pagination') ->assertVisible('[data-results]') ->assertSeeIn('[data-page-info]', 'Showing 1 to 3') ->assertSeeIn('[data-results]', 'Item 1'); }); it('navigates to page 2', function () { $this->visit(BASE_URL.'/pagination') ->click('[data-page="2"]') ->assertSeeIn('[data-page-info]', 'Showing 4 to 6') ->assertSeeIn('[data-results]', 'Item 4'); }); it('navigates to last page', function () { $this->visit(BASE_URL.'/pagination') ->click('[data-page="4"]') ->assertSeeIn('[data-page-info]', 'Showing 10 to 12') ->assertSeeIn('[data-results]', 'Item 12'); }); it('updates query string with page number', function () { $this->visit(BASE_URL.'/pagination') ->click('[data-page="3"]') ->assertQueryStringHas('page', '3'); }); it('highlights active page', function () { $this->visit(BASE_URL.'/pagination') ->assertAttribute('[data-page="1"]', 'class', 'active'); }); ================================================ FILE: tests/Browser/ProductListTest.php ================================================ visit(BASE_URL.'/product-list') ->assertVisible('#product-list') ->assertVisible('#fav-1') ->assertVisible('#fav-2') ->assertVisible('#fav-3') ->assertVisible('#status-1') ->assertVisible('#status-2') ->assertVisible('#status-3'); }); it('renders each favorite button with unfavorited state', function () { $this->visit(BASE_URL.'/product-list') ->assertSeeIn('#fav-1', '☆') ->assertSeeIn('#fav-2', '☆') ->assertSeeIn('#fav-3', '☆'); }); it('renders each status dropdown with correct initial status', function () { $this->visit(BASE_URL.'/product-list') ->assertSeeIn('#status-1 [data-status]', 'Active') ->assertSeeIn('#status-2 [data-status]', 'Draft') ->assertSeeIn('#status-3 [data-status]', 'Archived'); }); it('toggles favorite on one item without affecting others', function () { $page = $this->visit(BASE_URL.'/product-list') ->assertSeeIn('#fav-1', '☆') ->assertSeeIn('#fav-2', '☆'); $page->click('#fav-1 [data-action="toggle"]') ->assertSeeIn('#fav-1', '★') ->assertSeeIn('#fav-2', '☆'); }); it('toggles favorite back to unfavorited', function () { $page = $this->visit(BASE_URL.'/product-list') ->assertSeeIn('#fav-2', '☆'); $page->click('#fav-2 [data-action="toggle"]') ->assertSeeIn('#fav-2', '★') ->wait(0.3); $page->click('#fav-2 [data-action="toggle"]') ->assertSeeIn('#fav-2', '☆'); }); it('can favorite multiple items independently', function () { $page = $this->visit(BASE_URL.'/product-list') ->assertSeeIn('#fav-1', '☆'); $page->click('#fav-1 [data-action="toggle"]') ->assertSeeIn('#fav-1', '★') ->wait(0.3); $page->click('#fav-3 [data-action="toggle"]') ->assertSeeIn('#fav-3', '★'); $page->assertSeeIn('#fav-2', '☆'); }); it('opens status dropdown menu', function () { $page = $this->visit(BASE_URL.'/product-list'); $page->assertNotPresent('#status-1 [data-menu]'); $page->click('#status-1 [data-action="toggle-menu"]') ->assertVisible('#status-1 [data-menu]') ->assertSeeIn('#status-1 [data-option="active"]', 'Active') ->assertSeeIn('#status-1 [data-option="draft"]', 'Draft') ->assertSeeIn('#status-1 [data-option="archived"]', 'Archived'); }); it('changes status via dropdown without affecting other items', function () { $page = $this->visit(BASE_URL.'/product-list'); $page->click('#status-1 [data-action="toggle-menu"]') ->assertVisible('#status-1 [data-menu]') ->wait(0.3); $page->click('#status-1 [data-option="draft"]') ->assertSeeIn('#status-1 [data-status]', 'Draft') ->assertNotPresent('#status-1 [data-menu]') ->assertSeeIn('#status-2 [data-status]', 'Draft') ->assertSeeIn('#status-3 [data-status]', 'Archived'); }); it('each dropdown operates independently', function () { $page = $this->visit(BASE_URL.'/product-list'); $page->click('#status-3 [data-action="toggle-menu"]') ->assertVisible('#status-3 [data-menu]') ->wait(0.3); $page->click('#status-3 [data-option="active"]') ->assertSeeIn('#status-3 [data-status]', 'Active'); $page->assertSeeIn('#status-1 [data-status]', 'Active') ->assertSeeIn('#status-2 [data-status]', 'Draft'); }); it('each component has unique yoyo IDs in the DOM', function () { $this->visit(BASE_URL.'/product-list') ->assertSourceHas('id="fav-1"') ->assertSourceHas('id="fav-2"') ->assertSourceHas('id="fav-3"') ->assertSourceHas('id="status-1"') ->assertSourceHas('id="status-2"') ->assertSourceHas('id="status-3"') ->assertSourceHas('yoyo:name="favorite-button"') ->assertSourceHas('yoyo:name="status-dropdown"'); }); ================================================ FILE: tests/Browser/ResponseHeadersTest.php ================================================ visit(BASE_URL.'/response-headers') ->assertVisible('#response-headers-test') ->assertSeeIn('#rh-message', 'initial') ->assertSeeIn('#retarget-content', 'original'); }); it('retargets response to a different element via HX-Retarget', function () { // HX-Retarget with outerHTML swap replaces #retarget-receiver with the component response $this->visit(BASE_URL.'/response-headers') ->assertSeeIn('#retarget-content', 'original') ->click('#btn-retarget') ->assertSee('retargeted content'); }); it('changes swap strategy to innerHTML via HX-Reswap', function () { // Default swap is outerHTML — the component root is replaced entirely. // With HX-Reswap: innerHTML, the component root stays and content goes inside it. $this->visit(BASE_URL.'/response-headers') ->click('#btn-reswap') ->assertSeeIn('#response-headers', 'inner-swapped'); }); it('triggers client-side event via HX-Trigger', function () { $this->visit(BASE_URL.'/response-headers') ->click('#btn-trigger') ->assertSeeIn('#event-log', 'custom-event,'); }); it('triggers client-side event after settle via HX-Trigger-After-Settle', function () { $this->visit(BASE_URL.'/response-headers') ->click('#btn-trigger-settle') ->assertSeeIn('#event-log', 'settle-event,'); }); it('executes pushUrl action and re-renders component', function () { // URL push is not assertable in browser tests because historyEnabled is false // in the test server config. Feature tests verify the HX-Push-Url header is set. $this->visit(BASE_URL.'/response-headers') ->click('#btn-push-url') ->assertSeeIn('#rh-message', 'url-pushed'); }); it('executes replaceUrl action and re-renders component', function () { // Same as pushUrl — HX-Replace-Url header verified in feature tests. $this->visit(BASE_URL.'/response-headers') ->click('#btn-replace-url') ->assertSeeIn('#rh-message', 'url-replaced'); }); ================================================ FILE: tests/Browser/SkipRenderTest.php ================================================ visit(BASE_URL.'/skip-render') ->assertVisible('#skip-render-test') ->assertVisible('#item-1') ->assertVisible('#item-2') ->assertSeeIn('#item-1 [data-title]', 'First Item') ->assertSeeIn('#item-2 [data-title]', 'Second Item'); }); it('keeps component in DOM after skipRender (204)', function () { $page = $this->visit(BASE_URL.'/skip-render') ->assertSeeIn('#item-1 [data-title]', 'First Item'); // Click delete — should return 204, no swap $page->click('#item-1 [data-action="delete"]') ->waitForEvent('networkidle'); // Component should still be in the DOM (204 = no swap) $page->assertVisible('#item-1') ->assertSeeIn('#item-1 [data-title]', 'First Item'); }); it('does not affect other components on 204', function () { $page = $this->visit(BASE_URL.'/skip-render') ->assertSeeIn('#item-2 [data-title]', 'Second Item'); $page->click('#item-1 [data-action="delete"]') ->waitForEvent('networkidle'); $page->assertVisible('#item-2') ->assertSeeIn('#item-2 [data-title]', 'Second Item'); }); ================================================ FILE: tests/Browser/TodoListTest.php ================================================ visit(BASE_URL.'/todo-list') ->assertVisible('#todo-list') ->assertVisible('[data-entries]') ->assertSee('Build a framework') ->assertSee('Buy groceries') ->assertSee('Write tests'); }); it('shows active item count', function () { $this->visit(BASE_URL.'/todo-list') ->assertVisible('[data-active-count]') ->assertSeeIn('[data-active-count]', 'items left'); }); it('adds a new todo item', function () { $this->visit(BASE_URL.'/todo-list') ->fill('input[name="task"]', 'New browser test task') ->keys('input[name="task"]', ['Enter']) ->assertSee('New browser test task'); }); it('toggles todo completion', function () { $this->visit(BASE_URL.'/todo-list') ->click('[data-todo-id="1"] input[type="checkbox"]') ->assertChecked('[data-todo-id="1"] input[type="checkbox"]'); }); it('deletes a todo item', function () { $this->visit(BASE_URL.'/todo-list') ->assertSee('Buy groceries') ->click('[data-todo-id="2"] [data-delete]') ->assertDontSee('Buy groceries'); }); ================================================ FILE: tests/Browser/bootstrap.php ================================================ configure([ 'url' => '/yoyo', 'namespace' => 'Tests\\Browser\\Components\\', ]); $yoyo->registerViewProvider(function () { return new YoyoViewProvider(new View(__DIR__.'/views')); }); $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // Yoyo AJAX endpoint if ($uri === '/yoyo') { echo $yoyo->update(); exit; } // Serve yoyo.js from src if ($uri === '/yoyo.js' || $uri === '/assets/js/yoyo.js') { header('Content-Type: application/javascript'); readfile(__DIR__.'/../../../src/assets/js/yoyo.js'); exit; } // Component isolation pages $page = ltrim($uri, '/') ?: 'index'; $pagePath = __DIR__.'/pages/'.$page.'.php'; if (file_exists($pagePath)) { ob_start(); include $pagePath; $content = ob_get_clean(); echo $content; exit; } http_response_code(404); echo '404 Not Found'; ================================================ FILE: tests/Browser/server/layout.php ================================================ <?php echo $title; ?>
'dispatch-listener']); ?>
'unchanged', ], ['id' => 'dispatch-bystander']); ?>
0, ], ['id' => 'badge']); ?>
'Send Email', ], ['id' => 'btn-email']); ?> 'Add Item', ], ['id' => 'btn-add']); ?>
'Counter', 'live-search' => 'Live Search', 'form' => 'Form', 'todo-list' => 'Todo List', 'pagination' => 'Pagination', ]; $html = '

Yoyo Browser Test Components

'; render_page('Yoyo Browser Tests', $html); ================================================ FILE: tests/Browser/server/pages/live-search.php ================================================
'wizard']); ?>
1, 'name' => 'Widget Alpha', 'status' => 'active'], ['id' => 2, 'name' => 'Widget Beta', 'status' => 'draft'], ['id' => 3, 'name' => 'Widget Gamma', 'status' => 'archived'], ]; ob_start(); ?>
NameFavoriteStatus
$product['id'], 'isFavorited' => 0, ], ['id' => 'fav-'.$product['id']]); ?> $product['id'], 'status' => $product['status'], ], ['id' => 'status-'.$product['id']]); ?>
'response-headers']); ?>
original
1, 'title' => 'First Item', ], ['id' => 'item-1']); ?> 2, 'title' => 'Second Item', ], ['id' => 'item-2']); ?>
================================================ FILE: tests/Browser/server/views/counter.php ================================================
================================================ FILE: tests/Browser/server/views/delete-item.php ================================================
================================================ FILE: tests/Browser/server/views/dispatch-bystander.php ================================================
================================================ FILE: tests/Browser/server/views/dispatch-listener.php ================================================
================================================ FILE: tests/Browser/server/views/favorite-button.php ================================================
================================================ FILE: tests/Browser/server/views/form.php ================================================

Thank you for registering!

================================================ FILE: tests/Browser/server/views/live-search.php ================================================ ================================================ FILE: tests/Browser/server/views/modal-trigger.php ================================================

Modal Content

This is the modal body.

================================================ FILE: tests/Browser/server/views/multi-screen.php ================================================
actionMatches(['render', 'closeModal'])): ?>

Ready to begin

actionMatches('open')): ?>
actionMatches('submit')): ?>

Submitted:

================================================ FILE: tests/Browser/server/views/notification-badge.php ================================================
================================================ FILE: tests/Browser/server/views/null-prop.php ================================================
================================================ FILE: tests/Browser/server/views/pagination.php ================================================ ================================================ FILE: tests/Browser/server/views/response-headers.php ================================================
================================================ FILE: tests/Browser/server/views/status-dropdown.php ================================================
================================================ FILE: tests/Browser/server/views/todo-list.php ================================================
entries): ?>
    entries as $entry): ?>
  • />
activeCount; ?> items left
================================================ FILE: tests/Feature/AnonymousComponentTest.php ================================================ throws(ComponentNotFound::class); it('renders anonymous component', function () { expect(render('foo'))->toContain('default foo'); }); it('updates anonymous component', function () { expect(update('foo'))->toContain('default bar'); }); it('loads anonymous component with a registered alias', function () { \Clickfwd\Yoyo\Yoyo::registerComponent('awesome', 'registered-anon'); expect(render('awesome'))->toContain('id="registered-anon"'); }); it('renders anonymous component in sub-directory', function () { expect(render('account.login'))->toContain('app/resources/views/yoyo/account/login.php'); }); ================================================ FILE: tests/Feature/BladeTest.php ================================================ toContain('blade foo'); }); it('updates anonymous component', function () { expect(update('foo'))->toContain('blade bar'); }); it('renders anonymous component form different location', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->getFinder()->flush(); $view->prependLocation(__DIR__.'/../app-another/views'); expect(render('foo'))->toContain('blade foo from another app'); }); it('renders anonymous component using a view namespace', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->getFinder()->flush(); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); expect(render('packagename::foo'))->toContain('blade foo from another app'); }); it('renders dynamic component using a view and class namespace', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->getFinder()->flush(); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); Yoyo::getInstance()->componentNamespace('packagename', 'Tests\\AppAnother\\Yoyo'); expect(render('packagename::counter', ['count' => 3]))->toContain('The count is now 3'); }); it('can render nested components with @yoyo directive', function () { $output = render('parent', ['data' => [1, 2, 3]], ['id' => 'parent']); expect(htmlformat($output))->toEqual(response('nested.blade')); }); it('renders anonymous component in subdirectory', function () { expect(render('account.login'))->toContain('blade:app/resources/views/yoyo/account/login.php'); }); it('renders dynamic component in subdirectory', function () { expect(render('account.register'))->toContain('blade:Please register to access this page'); }); it('renders blade template within Yoyo', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->getFinder()->flush(); $view->addLocation(__DIR__.'/../app/resources/views'); expect(render('layout-a'))->toContain('app/resources/views/components/select.blade.php'); }); it('renders namespaced blade template within Yoyo', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->getFinder()->flush(); $view->addLocation(__DIR__.'/../app/resources/views'); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); expect(render('layout-b'))->toContain('app-another/views/components/input.blade.php'); }); ================================================ FILE: tests/Feature/ComponentLifecycleTest.php ================================================ group('component-lifecycle'); beforeAll(function () { yoyo_view(); }); beforeEach(function () { // Reset singleton services to prevent cross-test pollution foreach ([BrowserEventsService::class, PageRedirectService::class, Response::class] as $class) { $ref = new ReflectionClass($class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, null); } }); // --- Listeners --- it('renders component with listeners in trigger attribute', function () { $output = render('component-with-listeners'); expect($output) ->toContain('itemAdded') ->toContain('id="component-with-listeners"'); }); it('includes mapped listener events in trigger', function () { $output = render('component-with-listeners'); // Both itemAdded and refresh should be in the trigger attribute expect($output)->toContain('itemAdded'); }); // --- Computed properties with arguments --- it('renders computed property with arguments', function () { $output = render('component-with-computed-args'); expect($output) ->toContain('Hello, Alice!') ->toContain('Hello, Bob!'); }); // --- Redirect --- it('sets redirect property via redirect method', function () { mockYoyoGetRequest('http://example.com/', 'component-with-redirect/save', 'component-with-redirect'); $output = yoyo_update(); $responseHeaders = headers(); resetYoyoRequest(); expect($responseHeaders)->toHaveKey('Yoyo-Redirect', '/success'); }); // --- Emit and browser events --- it('emits events via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-emit/doEmit', 'component-with-emit'); $output = yoyo_update(); $responseHeaders = headers(); resetYoyoRequest(); $events = json_decode($responseHeaders['Yoyo-Emit'], true); expect($events)->toBeArray(); expect($events[0]['event'])->toBe('testEvent'); // Params are wrapped in an array due to variadic forwarding in BrowserEvents trait expect($events[0]['params'])->toBeArray(); expect($events[0]['params'][0])->toMatchArray(['key' => 'value']); }); it('emits targeted events via emitTo', function () { mockYoyoGetRequest('http://example.com/', 'component-with-emit/doEmitTo', 'component-with-emit'); $output = yoyo_update(); $responseHeaders = headers(); resetYoyoRequest(); $events = json_decode($responseHeaders['Yoyo-Emit'], true); expect($events)->toBeArray(); expect($events[0])->toMatchArray([ 'event' => 'targetEvent', 'component' => 'other-component', ]); }); it('dispatches browser events', function () { mockYoyoGetRequest('http://example.com/', 'component-with-emit/doBrowserEvent', 'component-with-emit'); $output = yoyo_update(); $responseHeaders = headers(); resetYoyoRequest(); $browserEvents = json_decode($responseHeaders['Yoyo-Browser-Event'], true); expect($browserEvents)->toBeArray(); expect($browserEvents[0])->toMatchArray([ 'event' => 'notification', 'params' => ['message' => 'done'], ]); }); // --- Swap modifiers --- it('adds swap modifier headers via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-swap-modifiers/doSwap', 'component-with-swap-modifiers'); $output = yoyo_update(); $responseHeaders = headers(); resetYoyoRequest(); expect($responseHeaders)->toHaveKey('Yoyo-Swap-Modifier', 'transition:true swap:500ms'); }); // --- Counter state management --- it('increments counter and emits event', function () { $output = update('counter', 'increment'); expect($output)->toContain('The count is now 1'); }); it('renders counter with custom initial value', function () { $output = render('counter', ['count' => 10]); expect($output)->toContain('The count is now 10'); }); // --- Protected method blocking --- it('prevents calling boot method directly', function () { update('counter', 'boot'); })->throws(NonPublicComponentMethodCall::class); it('prevents calling getName method as action', function () { update('counter', 'getName'); })->throws(NonPublicComponentMethodCall::class); it('prevents calling getComponentId method as action', function () { update('counter', 'getComponentId'); })->throws(NonPublicComponentMethodCall::class); // --- Component set() method --- it('passes view data set via set() method', function () { $output = render('set-view-data'); expect($output)->toContain('bar-baz'); }); // --- Sub-directory components --- it('resolves component class in sub-directory via dot notation', function () { $output = render('account.register'); expect($output)->toContain('Please register to access this page'); }); ================================================ FILE: tests/Feature/ComponentResolverTest.php ================================================ registerComponentResolver(new CustomComponentResolver()); $yoyo->registerComponentResolver(new BladeComponentResolver()); $yoyo->registerComponentResolver(new TwigComponentResolver()); expect(render('foo', ['yoyo:resolver' => 'custom']))->toContain('default foo') ->and(render('foo', ['yoyo:resolver' => 'blade']))->toContain('blade foo') ->and(render('foo', ['yoyo:resolver' => 'twig']))->toContain('twig foo'); }); ================================================ FILE: tests/Feature/DispatchEventTest.php ================================================ group('dispatch'); beforeAll(function () { yoyo_view(); }); beforeEach(function () { // Reset singletons for clean state (mirrors ComponentLifecycleTest) $ref = new ReflectionClass(BrowserEventsService::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, null); $ref = new ReflectionClass(Response::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, null); }); afterEach(function () { resetYoyoRequest(); }); // ============================================================================= // JS Dispatch - Associative (named) params: Yoyo.dispatch('event', { key: val }) // ============================================================================= it('handles JS dispatch with single named parameter', function () { // Simulates: Yoyo.dispatch('post-created', { postId: 42 }) mockYoyoGetRequest('http://example.com/', 'dispatch-listener/post-created', '', [ 'eventParams' => json_encode(['postId' => 42]), ]); $output = yoyo_update(); expect($output)->toContain('Post created with ID: 42'); }); it('handles JS dispatch with multiple named parameters', function () { // Simulates: Yoyo.dispatch('status-changed', { status: 'active', reason: 'manual' }) mockYoyoGetRequest('http://example.com/', 'dispatch-listener/status-changed', '', [ 'eventParams' => json_encode(['status' => 'active', 'reason' => 'manual']), ]); $output = yoyo_update(); expect($output)->toContain('Status: active, Reason: manual'); }); it('handles JS dispatch with named params where optional param is omitted', function () { // Simulates: Yoyo.dispatch('status-changed', { status: 'paused' }) // 'reason' has a default value of 'none', should use it mockYoyoGetRequest('http://example.com/', 'dispatch-listener/status-changed', '', [ 'eventParams' => json_encode(['status' => 'paused']), ]); $output = yoyo_update(); expect($output)->toContain('Status: paused, Reason: none'); }); // ============================================================================= // JS Dispatch - No params: Yoyo.dispatch('event') // ============================================================================= it('handles JS dispatch without parameters', function () { // Simulates: Yoyo.dispatch('simple-refresh') with explicit empty object // Must use stdClass to produce JSON object '{}' matching JS JSON.stringify({}) // json_encode([]) produces '[]' which is the server-side emit case — different path mockYoyoGetRequest('http://example.com/', 'dispatch-listener/simple-refresh', '', [ 'eventParams' => json_encode(new stdClass()), ]); $output = yoyo_update(); expect($output)->toContain('Refreshed without params'); }); // ============================================================================= // JS Dispatch - Missing required parameter // ============================================================================= it('throws exception when required named parameter is missing', function () { // Simulates: Yoyo.dispatch('status-changed', { reason: 'manual' }) // 'status' is required but missing from the payload mockYoyoGetRequest('http://example.com/', 'dispatch-listener/status-changed', '', [ 'eventParams' => json_encode(['reason' => 'manual']), ]); yoyo_update(); })->throws(\InvalidArgumentException::class); // ============================================================================= // Server-side emit - Sequential (positional) params (existing behavior) // ============================================================================= it('handles server-side emit with sequential parameters', function () { // Simulates server-side: $this->emit('post-created', 99) // Server emit sends params as numerically indexed array mockYoyoGetRequest('http://example.com/', 'dispatch-listener/post-created', '', [ 'eventParams' => json_encode([99]), ]); $output = yoyo_update(); expect($output)->toContain('Post created with ID: 99'); }); it('handles server-side emit with multiple sequential parameters', function () { // Simulates: $this->emit('multi-param', 'My Title', 'My Body', 5) mockYoyoGetRequest('http://example.com/', 'dispatch-listener/multi-param', '', [ 'eventParams' => json_encode(['My Title', 'My Body', 5]), ]); $output = yoyo_update(); expect($output)->toContain('Title: My Title, Body: My Body, Category: 5'); }); // ============================================================================= // Security - only declared listeners can be triggered // ============================================================================= it('rejects dispatch to non-listener event name', function () { // Event name 'nonExistentEvent' is not declared in $listeners // Exception fires during listener routing, before eventParams are read mockYoyoGetRequest('http://example.com/', 'dispatch-listener/nonExistentEvent', '', []); yoyo_update(); })->throws(ComponentMethodNotFound::class); ================================================ FILE: tests/Feature/DynamicComponentTest.php ================================================ group('unit-dynamic'); beforeAll(function () { yoyo_view(); }); it('throws expection on component class not found', function () { render('random'); })->throws(ComponentNotFound::class); it('renders counter component', function () { $vars = encode_vals([ yoprefix_value('id') => 'counter', 'count' => 0, ]); expect(render('counter'))->toContain(hxattr('vals', $vars)); }); it('uses passed variable value set in component', function () { $vars = encode_vals([yoprefix_value('id') => 'counter', 'count' => 3]); expect(render('counter', ['count' => 3]))->toContain(hxattr('vals', $vars)); }); it('updates component', function () { expect(update('counter', 'increment'))->toContain('The count is now 1'); }); it('uses passed variable value in component action', function () { $vars = encode_vals([yoprefix_value('id') => 'counter', 'count' => 1]); expect(update('counter', 'increment'))->toContain(hxattr('vals', $vars)); }); it('throws exception when component method not found', function () { update('counter', 'random'); })->throws(ComponentMethodNotFound::class); it('uses a computed property', function () { $output = render('computed-property'); expect(htmlformat($output))->toEqual(response('computed-property')); }); it('uses the computed property cache', function () { $output = render('computed-property-cache'); expect(htmlformat($output))->toEqual(response('computed-property-cache')); }); it('can set component view data', function () { expect(render('set-view-data'))->toContain('bar-baz'); }); it('passes action parameters to component method arguments', function () { mockYoyoGetRequest('http://example.com/', 'action-arguments/someAction', '', [ 'actionArgs' => [1,'foo'], ]); $output = yoyo_update(); resetYoyoRequest(); expect(htmlformat($output))->toEqual(response('action-arguments')); }); it('loads dynamic component with registered alias', function () { Yoyo::registerComponent('registeredalias', \Tests\App\Yoyo\Registered::class); expect(render('registeredalias'))->toContain('id="registered"'); }); it('returns empty response with 204 status on skipRender', function () { expect(render('empty-response'))->toBeEmpty()->and(http_response_code())->toBe(204); })->throws(BypassRenderMethod::class); it('returns empty response with 200 status on skipRenderAndReplace', function () { expect(render('empty-response-and-remove'))->toBeEmpty()->and(http_response_code())->toBe(200); }); it('dynamically resolves class and named arguments in mount method', function () { mockYoyoGetRequest('http://example.com/', 'ependency-injection-class-with-named-argument-mapping', '', [ 'id' => 100, ]); expect(render('dependency-injection-class-with-named-argument-mapping'))->toContain('the comment title-100'); resetYoyoRequest(); }); it('executes trait lifecycle hooks', function () { expect(render('component-with-trait'))->toContain('{ComponentWithTrait} saw that {mountWithFramework} was here'); }); it('it aborts component execution and throws an exception', function () { try { render('abort'); } catch (HttpException $e) { expect($e->getHeaders())->toMatchArray(['foo' => 'bar']) ->and($e->getStatusCode())->toBe(404) ->and($e->getMessage())->toBe('not found'); throw $e; } })->throws(HttpException::class); it('renders dynamic component in sub-directory', function () { expect(render('account.register'))->toContain('Please register to access this page'); }); it('renders component using dynamic properties', function () { $vars = encode_vals([ yoprefix_value('id') => 'counter', 'count' => '', ]); expect(render('counter_dynamic_properties'))->toContain(hxattr('vals', $vars)); }); it('updates component with dynamic properties', function () { expect(update('counter_dynamic_properties', 'increment'))->toContain('The count is now 1'); }); // Variadic Parameters Tests it('handles variadic parameters with no arguments', function () { mockYoyoPostRequest('/', 'variadic-parameters/onlyVariadic', 'variadic-parameters', [ 'actionArgs' => [], ]); expect(yoyo_update())->toContain('Received: []'); }); it('handles variadic parameters with multiple arguments', function () { mockYoyoPostRequest('/', 'variadic-parameters/onlyVariadic', 'variadic-parameters', [ 'actionArgs' => ['arg1', 'arg2', 'arg3'], ]); expect(yoyo_update())->toContain('Received: ["arg1","arg2","arg3"]'); }); it('handles mixed regular and variadic parameters', function () { mockYoyoPostRequest('/', 'variadic-parameters/mixedVariadic', 'variadic-parameters', [ 'actionArgs' => ['first', 'second', 'third'], ]); expect(yoyo_update())->toContain('First: first, Rest: ["second","third"]'); }); it('handles optional and variadic parameters', function () { mockYoyoPostRequest('/', 'variadic-parameters/optionalAndVariadic', 'variadic-parameters', [ 'actionArgs' => ['required_value', 'optional_value', 'extra1', 'extra2'], ]); expect(yoyo_update())->toContain('Required: required_value, Optional: optional_value, Extra: ["extra1","extra2"]'); }); // Dependency Injection Tests it('handles action with only typed parameters', function () { mockYoyoPostRequest('/', 'dependency-injection-action/onlyTyped', 'dependency-injection-action', [ 'actionArgs' => [], ]); expect(yoyo_update())->toContain('Post title: the comment title'); }); it('handles action with multiple typed parameters', function () { mockYoyoPostRequest('/', 'dependency-injection-action/multipleTyped', 'dependency-injection-action', [ 'actionArgs' => [], ]); expect(yoyo_update())->toContain('Post: the comment title, Comment: the comment body'); }); it('handles action with mixed typed and regular parameters', function () { mockYoyoPostRequest('/', 'dependency-injection-action/mixedTypedAndRegular', 'dependency-injection-action', [ 'actionArgs' => [123, 'inactive'], ]); expect(yoyo_update())->toContain('Post: the comment title, ID: 123, Status: inactive'); }); it('handles action with typed and variadic parameters', function () { mockYoyoPostRequest('/', 'dependency-injection-action/typedWithVariadic', 'dependency-injection-action', [ 'actionArgs' => ['php', 'laravel', 'yoyo'], ]); expect(yoyo_update())->toContain('Post: the comment title, Tags: ["php","laravel","yoyo"]'); }); it('handles action with typed and optional regular parameter without value', function () { mockYoyoPostRequest('/', 'dependency-injection-action/typedWithOptional', 'dependency-injection-action', [ 'actionArgs' => [], ]); expect(yoyo_update())->toContain('Post: the comment title, Status: default'); }); it('handles action with typed and optional regular parameter with value', function () { mockYoyoPostRequest('/', 'dependency-injection-action/typedWithOptional', 'dependency-injection-action', [ 'actionArgs' => ['active'], ]); expect(yoyo_update())->toContain('Post: the comment title, Status: active'); }); ================================================ FILE: tests/Feature/NamespacedComponentTest.php ================================================ getViewProvider(); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); expect(render('packagename::foo'))->toContain('other foo from another app'); }); it('can render namespaced dynamic component', function () { $yoyo = Yoyo::getInstance(); $view = $yoyo->getViewProvider(); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); Yoyo::getInstance()->componentNamespace('packagename', 'Tests\\AppAnother\\Yoyo'); expect(render('packagename::counter', ['count' => 3]))->toContain('The count is now 3'); }); it('can render namespaced anonymous component with custom resolver', function () { $yoyo = Yoyo::getInstance(); Container::getInstance()->flush(); $yoyo->registerComponentResolver(new BladeComponentResolver()); $view = $yoyo->getViewProvider('blade'); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); expect(render('packagename::foo', [ 'yoyo:resolver' => 'blade', ]))->toContain('blade foo from another app'); }); it('can render namespaced dynamic component with custom resolver', function () { $yoyo = Yoyo::getInstance(); Container::getInstance()->flush(); $yoyo->registerComponentResolver(new BladeComponentResolver()); $view = $yoyo->getViewProvider('blade'); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); $yoyo->componentNamespace('packagename', 'Tests\\AppAnother\\Yoyo'); expect(render('packagename::counter', [ 'yoyo:resolver' => 'blade', 'count' => 3, ]))->toContain('The count is now 3'); }); ================================================ FILE: tests/Feature/NestedComponentTest.php ================================================ group('unit-nested'); beforeAll(function () { yoyo_view(); }); it('can render nested components', function () { $output = render('parent', ['data' => [1, 2, 3]], ['id' => 'parent']); expect(htmlformat($output))->toEqual(response('nested')); }); ================================================ FILE: tests/Feature/ResponseHeadersComponentTest.php ================================================ group('response-headers'); beforeAll(function () { yoyo_view(); }); beforeEach(function () { $ref = new ReflectionClass(BrowserEventsService::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, null); $ref = new ReflectionClass(Response::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, null); }); afterEach(function () { resetYoyoRequest(); }); // --- Retarget --- it('sets HX-Retarget header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doRetarget', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Retarget', '#other-target'); expect($output)->toContain('retargeted'); }); // --- Reswap --- it('sets HX-Reswap header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doReswap', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Reswap', 'innerHTML'); expect($output)->toContain('reswapped'); }); // --- Reselect --- it('sets HX-Reselect header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doReselect', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Reselect', '#selected-part'); expect($output)->toContain('reselected'); }); // --- Location --- it('sets HX-Location header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doLocation', 'component-with-response-headers'); yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Location', '/new-location'); }); // --- Push URL --- it('sets HX-Push-Url header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doPushUrl', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Push-Url', '/pushed-url'); expect($output)->toContain('url-pushed'); }); // --- Replace URL --- it('sets HX-Replace-Url header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doReplaceUrl', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Replace-Url', '/replaced-url'); expect($output)->toContain('url-replaced'); }); // --- Redirect (Response-level, not Component-level) --- it('sets HX-Redirect header via response object', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doRedirect', 'component-with-response-headers'); yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Redirect', '/redirected'); }); // --- Refresh --- it('sets HX-Refresh header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doRefresh', 'component-with-response-headers'); yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Refresh', 'true'); }); // --- Trigger --- it('sets HX-Trigger header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doTrigger', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Trigger', 'custom-event'); expect($output)->toContain('triggered'); }); // --- Trigger After Swap --- it('sets HX-Trigger-After-Swap header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doTriggerAfterSwap', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Trigger-After-Swap', 'swap-event'); expect($output)->toContain('trigger-after-swap'); }); // --- Trigger After Settle --- it('sets HX-Trigger-After-Settle header via component action', function () { mockYoyoGetRequest('http://example.com/', 'component-with-response-headers/doTriggerAfterSettle', 'component-with-response-headers'); $output = yoyo_update(); $responseHeaders = headers(); expect($responseHeaders)->toHaveKey('HX-Trigger-After-Settle', 'settle-event'); expect($output)->toContain('trigger-after-settle'); }); ================================================ FILE: tests/Feature/TwigTest.php ================================================ toContain('twig foo'); }); it('updates anonymous component', function () { expect(update('foo'))->toContain('twig bar'); }); it('can render anonymous component form different location', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->prependLocation(__DIR__.'/../app-another/views'); expect(render('foo'))->toContain('twig foo from another app'); }); it('can render anonymous component using a view namespace', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); expect(render('packagename::foo'))->toContain('twig foo from another app'); }); it('can render dynamic component using a view and class namespace', function () { $view = Yoyo::getInstance()->getViewProvider(); $view->addNamespace('packagename', __DIR__.'/../app-another/views'); Yoyo::getInstance()->componentNamespace('packagename', 'Tests\\AppAnother\\Yoyo'); expect(render('packagename::counter', ['count' => 3]))->toContain('The count is now 3'); }); it('can render nested components with yoyo function', function () { $output = render('parent', ['data' => [1, 2, 3]], ['id' => 'parent']); expect(htmlformat($output))->toEqual(response('nested.twig')); }); ================================================ FILE: tests/Helpers.php ================================================ registerViewProvider(function () { return new YoyoViewProvider(new View(__DIR__.'/app/resources/views/yoyo')); }); } function yoyo_instance() { $yoyo = Yoyo::getInstance(); return $yoyo; } function compile_html($name, $html, $spinning = false) { $yoyo = yoyo_instance(); return normalizeDomOutput($yoyo->mount($name)->compile('anonymous', $html, $spinning)); } function compile_html_with_vars($name, $html, $vars, $spinning = false) { $yoyo = yoyo_instance(); return normalizeDomOutput($yoyo->mount($name, $vars)->compile('anonymous', $html, $spinning)); } function render($name, $variables = [], $attributes = []) { $yoyo = yoyo_instance(); return normalizeDomOutput($yoyo->mount($name, $variables, $attributes)->render()); } function update($name, $action = 'render', $variables = [], $attributes = []) { $yoyo = yoyo_instance(); return normalizeDomOutput($yoyo->mount($name, $variables, $attributes, $action)->refresh()); } function yoyo_update() { return normalizeDomOutput((yoyo_instance())->update()); } /** * Normalize DOMDocument attribute encoding for cross-PHP-version compatibility. * PHP 8.3+ outputs: hx-vals="{"key":"val"}" * PHP 8.0-8.2 outputs: hx-vals='{"key":"val"}' * This normalizes to the single-quoted format. */ function normalizeDomOutput($html) { return preg_replace_callback( '/hx-vals="([^"]*)"/', function ($m) { return "hx-vals='" . str_replace('"', '"', $m[1]) . "'"; }, $html ); } function mockYoyoGetRequest($url, $component, $target = '', $parameters = []) { $request = array_merge([ 'component' => $component, ], $parameters); $server = [ 'REQUEST_METHOD' => 'GET', 'HTTP_HX_REQUEST' => true, 'HTTP_HX_CURRENT_URL' => $url, 'HTTP_HX_TARGET' => $target, ]; $requestService = Yoyo::request()->mock($request, $server); return $requestService; } function mockYoyoPostRequest($url, $component, $target = '', $parameters = []) { $request = array_merge([ 'component' => $component, ], $parameters); $server = [ 'REQUEST_METHOD' => 'POST', 'HTTP_HX_REQUEST' => true, 'HTTP_HX_CURRENT_URL' => $url, 'HTTP_HX_TARGET' => $target, ]; $requestService = Yoyo::request()->mock($request, $server); return $requestService; } function resetYoyoRequest() { Yoyo::request()->reset(); } function headers() { return (Response::getInstance())->getHeaders(); } function hxattr($name, $value = '') { return YoyoCompiler::hxprefix($name).addValue($value); } function yoattr($name, $value = '') { return YoyoCompiler::yoprefix($name).addValue($value); } function yoprefix_value($value) { return YoyoCompiler::yoprefix_value($value); } function encode_vals($vars) { return YoyoHelpers::encode_vals($vars); } function addValue($value = '') { if (! $value) { return ''; } if (YoyoHelpers::test_json($value)) { return "='".$value."'"; } return '="'.$value.'"'; } function response($filename) { $output = file_get_contents(__DIR__."/responses/$filename.html"); return htmlformat($output); } function htmlformat($html) { $html = preg_replace('!\s+!', ' ', $html); $html = preg_replace('/\>\s+\<', $html); return $html; } ================================================ FILE: tests/HelpersBlade.php ================================================ registerViewProvider(function () use ($blade) { return new BladeViewProvider($blade); }); } function blade() { // Use Application (which has terminating()) instead of base Container $app = new Application(); Container::setInstance($app); $app->bind(ApplicationContract::class, Application::class); $app->alias('view', ViewFactory::class); $app->extend('config', function ($config) { return is_array($config) ? new Fluent($config) : $config; }); $blade = new Blade(__DIR__.'/app/resources/views/yoyo', __DIR__.'/compiled', $app); $app->bind('view', function () use ($blade) { return $blade; }); (new YoyoServiceProvider($app))->boot(); return $blade; } ================================================ FILE: tests/HelpersTwig.php ================================================ registerViewProvider(function () { return new TwigViewProvider(twig()); }); } function twig() { $loader = new \Twig\Loader\FilesystemLoader([ __DIR__.'/app/resources/views/yoyo', ]); $twig = new \Twig\Environment($loader, [ 'cache' => __DIR__.'/compiled', 'auto_reload' => true, // 'debug' => true ]); // Add Yoyo's Twig Extension $twig->addExtension(new YoyoTwigExtension()); return $twig; } ================================================ FILE: tests/InitYoyoContainer.php ================================================ configure([ 'namespace' => 'Tests\\App\\Yoyo\\', ]); ================================================ FILE: tests/Pest.php ================================================ configure([ 'namespace' => 'Tests\\App\\Yoyo\\', ]); uses()->group('browser')->in('Browser'); ================================================ FILE: tests/Unit/BrowserEventsServiceTest.php ================================================ group('browser-events'); beforeEach(function () { // Reset singleton instances for clean state $ref = new ReflectionClass(BrowserEventsService::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, null); $ref = new ReflectionClass(Response::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, null); Yoyo::request()->mock([], [ 'REQUEST_METHOD' => 'GET', 'HTTP_HX_REQUEST' => true, ]); }); afterEach(function () { Yoyo::request()->reset(); }); it('emits an event with params', function () { $service = BrowserEventsService::getInstance(); $service->emit('testEvent', ['key' => 'value']); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $events = json_decode($headers['Yoyo-Emit'], true); expect($events)->toHaveCount(1); expect($events[0]['event'])->toBe('testEvent'); expect($events[0]['params'])->toBe(['key' => 'value']); }); it('emits targeted event with emitTo', function () { $service = BrowserEventsService::getInstance(); $service->emitTo('target-component', 'updateEvent', ['id' => 42]); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $events = json_decode($headers['Yoyo-Emit'], true); expect($events)->toHaveCount(1); expect($events[0])->toMatchArray([ 'event' => 'updateEvent', 'component' => 'target-component', ]); }); it('emits to selector with emitToWithSelector', function () { $service = BrowserEventsService::getInstance(); $service->emitToWithSelector('#my-element', 'selectorEvent', ['data' => true]); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $events = json_decode($headers['Yoyo-Emit'], true); expect($events)->toHaveCount(1); expect($events[0])->toMatchArray([ 'event' => 'selectorEvent', 'selector' => '#my-element', ]); }); it('emits self-targeted event', function () { Yoyo::request()->mock([ 'component' => 'my-component/action', ], [ 'REQUEST_METHOD' => 'GET', 'HTTP_HX_REQUEST' => true, ]); $service = BrowserEventsService::getInstance(); $service->emitSelf('selfEvent', ['status' => 'ok']); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $events = json_decode($headers['Yoyo-Emit'], true); expect($events)->toHaveCount(1); expect($events[0])->toMatchArray([ 'event' => 'selfEvent', 'component' => 'my-component', 'propagation' => 'self', ]); }); it('does not emit self when no component in request', function () { Yoyo::request()->mock([], [ 'REQUEST_METHOD' => 'GET', 'HTTP_HX_REQUEST' => true, ]); $service = BrowserEventsService::getInstance(); $service->emitSelf('selfEvent', ['status' => 'ok']); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $events = json_decode($headers['Yoyo-Emit'], true); expect($events)->toHaveCount(0); }); it('dispatches browser event', function () { $service = BrowserEventsService::getInstance(); $service->dispatchBrowserEvent('show-modal', ['title' => 'Confirm']); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $browserEvents = json_decode($headers['Yoyo-Browser-Event'], true); expect($browserEvents)->toHaveCount(1); expect($browserEvents[0])->toMatchArray([ 'event' => 'show-modal', 'params' => ['title' => 'Confirm'], ]); }); it('queues multiple events', function () { $service = BrowserEventsService::getInstance(); $service->emit('event1', ['a' => 1]); $service->emit('event2', ['b' => 2]); $service->emit('event3', ['c' => 3]); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $events = json_decode($headers['Yoyo-Emit'], true); expect($events)->toHaveCount(3); expect($events[0]['event'])->toBe('event1'); expect($events[1]['event'])->toBe('event2'); expect($events[2]['event'])->toBe('event3'); }); it('queues multiple browser events', function () { $service = BrowserEventsService::getInstance(); $service->dispatchBrowserEvent('toast', ['msg' => 'saved']); $service->dispatchBrowserEvent('scroll', ['to' => 'top']); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); $browserEvents = json_decode($headers['Yoyo-Browser-Event'], true); expect($browserEvents)->toHaveCount(2); }); it('dispatches empty arrays when no events queued', function () { $service = BrowserEventsService::getInstance(); $service->dispatch(); $headers = Response::getInstance()->getHeaders(); expect(json_decode($headers['Yoyo-Emit'], true))->toBe([]); expect(json_decode($headers['Yoyo-Browser-Event'], true))->toBe([]); }); ================================================ FILE: tests/Unit/BrowserEventsTest.php ================================================ toHaveKey('Yoyo-Emit'); expect($headers['Yoyo-Emit'])->toEqual('[{"event":"counter:updated","params":[{"count":1}]}]'); })->group('headers'); ================================================ FILE: tests/Unit/ClassHelpersTest.php ================================================ toContain('count'); expect($props)->not->toContain('yoyo_id'); }); it('returns same result on repeated calls', function () { $component = resolveComponent(); $first = ClassHelpers::getPublicProperties($component, Component::class); $second = ClassHelpers::getPublicProperties($component, Component::class); expect($first)->toEqual($second); }); it('returns default public vars', function () { $component = resolveComponent(); $defaults = ClassHelpers::getDefaultPublicVars($component, Component::class); expect($defaults)->toHaveKey('count'); expect($defaults['count'])->toBe(0); }); it('returns current public vars after mutation', function () { $component = resolveComponent(); $component->count = 5; $vars = ClassHelpers::getPublicVars($component, Component::class); expect($vars['count'])->toBe(5); }); it('returns public methods excluding base class', function () { $methods = ClassHelpers::getPublicMethods(Counter::class, ['render']); expect($methods)->toContain('increment'); }); it('discovers traits recursively', function () { $traits = ClassHelpers::classUsesRecursive(ComponentWithTrait::class); expect($traits)->not->toBeEmpty(); }); it('returns class basename from FQCN', function () { expect(ClassHelpers::classBasename(Counter::class))->toBe('Counter'); }); it('detects non-private methods', function () { expect(ClassHelpers::methodIsPrivate(Counter::class, 'increment'))->toBeFalse(); }); it('gets method parameter names', function () { $names = ClassHelpers::getMethodParameterNames(Counter::class, 'increment'); expect($names)->toBeArray(); }); it('returns method parameters with types', function () { $params = ClassHelpers::getMethodParametersWithTypes(Counter::class, 'increment'); expect($params)->toHaveKey('typed'); expect($params)->toHaveKey('regular'); }); it('detects variadic parameters', function () { expect(ClassHelpers::methodHasVariadicParameter(Counter::class, 'increment'))->toBeFalse(); }); // --- Caching tests --- it('returns cached result on second call (strict identity)', function () { $component = resolveComponent(); $first = ClassHelpers::getPublicProperties($component, Component::class); $second = ClassHelpers::getPublicProperties($component, Component::class); expect($first)->toBe($second); }); it('separates cache by class name', function () { $counter = resolveComponent(Counter::class, 'counter'); $computed = resolveComponent(ComputedProperty::class, 'computed-property'); $counterProps = ClassHelpers::getPublicProperties($counter, Component::class); $computedProps = ClassHelpers::getPublicProperties($computed, Component::class); expect($counterProps)->not->toEqual($computedProps); }); it('flushCache clears all caches', function () { $component = resolveComponent(); ClassHelpers::getPublicProperties($component, Component::class); ClassHelpers::getDefaultPublicVars($component, Component::class); ClassHelpers::getPublicMethods(Counter::class, ['render']); ClassHelpers::classUsesRecursive(ComponentWithTrait::class); ClassHelpers::flushCache(); // After flush, a new call should still return correct results $props = ClassHelpers::getPublicProperties($component, Component::class); expect($props)->toContain('count'); }); it('caches getDefaultPublicVars result', function () { $component = resolveComponent(); $first = ClassHelpers::getDefaultPublicVars($component, Component::class); $second = ClassHelpers::getDefaultPublicVars($component, Component::class); expect($first)->toBe($second); }); it('caches classUsesRecursive result', function () { $first = ClassHelpers::classUsesRecursive(ComponentWithTrait::class); $second = ClassHelpers::classUsesRecursive(ComponentWithTrait::class); expect($first)->toBe($second); }); it('caches getPublicMethods result', function () { $first = ClassHelpers::getPublicMethods(Counter::class, ['render']); $second = ClassHelpers::getPublicMethods(Counter::class, ['render']); expect($first)->toBe($second); }); ================================================ FILE: tests/Unit/ComponentResolverTest.php ================================================ resolveDynamic('counter', 'counter'))->toBeInstanceOf(Tests\App\Yoyo\Counter::class); }); it('resolves anonymous component', function () { $resolver = (new Clickfwd\Yoyo\ComponentResolver())(ContainerResolver::resolve()); expect($resolver->resolveAnonymous('foo', 'foo'))->toBeInstanceOf(Clickfwd\Yoyo\AnonymousComponent::class); }); it('resolves namespaced dynamic component', function () { $namespaces = ['packagename' => [ 'Tests\\AppAnother\\Yoyo', ]]; $resolver = (new Clickfwd\Yoyo\ComponentResolver())(ContainerResolver::resolve(), [], $namespaces); expect($resolver->resolveDynamic('foo', 'packagename::counter')) ->toBeInstanceOf(Tests\AppAnother\Yoyo\Counter::class); }); ================================================ FILE: tests/Unit/ComponentTest.php ================================================ group('component'); function makeComponent(string $class, string $id = 'test', string $name = 'test'): \Clickfwd\Yoyo\Component { $container = ContainerResolver::resolve(); $resolver = (new ComponentResolver())($container); return new $class($resolver, $id, $name); } // --- getListeners --- it('normalizes numeric listener keys to key=value pairs', function () { $component = makeComponent(Tests\App\Yoyo\ComponentWithListeners::class); $listeners = $component->getListeners(); expect($listeners)->toHaveKey('itemAdded', 'onItemAdded'); expect($listeners)->toHaveKey('refresh', 'refresh'); }); // --- set() method --- it('sets single view data key', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->set('foo', 'bar'); $ref = new ReflectionClass($component); $prop = $ref->getProperty('viewData'); $prop->setAccessible(true); expect($prop->getValue($component))->toMatchArray(['foo' => 'bar']); }); it('sets multiple view data keys via array', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->set(['a' => 1, 'b' => 2]); $ref = new ReflectionClass($component); $prop = $ref->getProperty('viewData'); $prop->setAccessible(true); expect($prop->getValue($component))->toMatchArray(['a' => 1, 'b' => 2]); }); it('merges view data on subsequent calls', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->set('x', 1); $component->set('y', 2); $ref = new ReflectionClass($component); $prop = $ref->getProperty('viewData'); $prop->setAccessible(true); expect($prop->getValue($component))->toMatchArray(['x' => 1, 'y' => 2]); }); // --- actionMatches --- it('matches single action string', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->setAction('increment'); expect($component->actionMatches('increment'))->toBeTrue(); expect($component->actionMatches('decrement'))->toBeFalse(); }); it('matches action from array', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->setAction('increment'); expect($component->actionMatches(['increment', 'decrement']))->toBeTrue(); expect($component->actionMatches(['save', 'delete']))->toBeFalse(); }); // --- Computed property caching --- it('caches computed property value', function () { $component = makeComponent(Tests\App\Yoyo\ComputedPropertyCache::class, 'test', 'computed-property-cache'); $component->boot([], []); // First call returns 1 and caches it $first = $component->testCount; // Second call returns cached value (still 1, not 2) $second = $component->testCount; expect($first)->toBe(1); expect($second)->toBe(1); }); it('clears all computed property cache', function () { $component = makeComponent(Tests\App\Yoyo\ComputedPropertyCache::class, 'test', 'computed-property-cache'); $component->boot([], []); $first = $component->testCount; expect($first)->toBe(1); $component->forgetComputed(); // After clearing cache, it recalculates $second = $component->testCount; expect($second)->toBe(2); }); it('clears specific computed property cache', function () { $component = makeComponent(Tests\App\Yoyo\ComputedPropertyCache::class, 'test', 'computed-property-cache'); $component->boot([], []); $first = $component->testCount; expect($first)->toBe(1); $component->forgetComputed('testCount'); $second = $component->testCount; expect($second)->toBe(2); }); it('clears multiple computed property caches via args', function () { $component = makeComponent(Tests\App\Yoyo\ComputedPropertyCache::class, 'test', 'computed-property-cache'); $component->boot([], []); $component->testCount; $component->forgetComputed('testCount', 'otherKey'); $second = $component->testCount; expect($second)->toBe(2); }); // --- Computed property with arguments (__call) --- it('calls computed property with arguments', function () { $component = makeComponent(Tests\App\Yoyo\ComponentWithComputedArgs::class, 'test', 'component-with-computed-args'); $component->boot([], []); expect($component->greeting('Alice'))->toBe('Hello, Alice!'); expect($component->greeting('Bob'))->toBe('Hello, Bob!'); }); it('caches computed property with arguments by arg hash', function () { $component = makeComponent(Tests\App\Yoyo\ComponentWithComputedArgs::class, 'test', 'component-with-computed-args'); $component->boot([], []); $first = $component->expensive(); $second = $component->expensive(); expect($first)->toBe($second); }); it('uses different cache keys for different arguments', function () { $component = makeComponent(Tests\App\Yoyo\ComponentWithComputedArgs::class, 'test', 'component-with-computed-args'); $component->boot([], []); $resultAlice = $component->greeting('Alice'); $resultBob = $component->greeting('Bob'); expect($resultAlice)->toBe('Hello, Alice!'); expect($resultBob)->toBe('Hello, Bob!'); expect($resultAlice)->not->toBe($resultBob); }); it('clears computed property cache with arguments', function () { $component = makeComponent(Tests\App\Yoyo\ComponentWithComputedArgs::class, 'test', 'component-with-computed-args'); $component->boot([], []); $first = $component->expensive(); $component->forgetComputedWithArgs('expensive'); // After clearing, next call recalculates and returns a different value $second = $component->expensive(); expect($second)->not->toBe($first); }); // --- __get and __call throw on unknown --- it('throws ComponentMethodNotFound for unknown property', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class, 'test', 'counter'); $component->boot([], []); $component->nonExistentProperty; })->throws(ComponentMethodNotFound::class); it('throws ComponentMethodNotFound for unknown method call', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class, 'test', 'counter'); $component->boot([], []); $component->nonExistentMethod(); })->throws(ComponentMethodNotFound::class); // --- spinning() --- it('sets spinning state and returns self', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $result = $component->spinning(true); expect($result)->toBe($component); $ref = new ReflectionClass($component); $prop = $ref->getProperty('spinning'); $prop->setAccessible(true); expect($prop->getValue($component))->toBeTrue(); }); // --- boot() --- it('sets public properties from variables', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->boot(['count' => 42], []); expect($component->count)->toBe(42); }); it('preserves default value when variable not provided', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->boot([], []); expect($component->count)->toBe(0); }); // --- getName, getComponentId --- it('returns component name', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class, 'counter-id', 'counter'); expect($component->getName())->toBe('counter'); }); it('returns component id', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class, 'counter-id', 'counter'); expect($component->getComponentId())->toBe('counter-id'); }); // --- getQueryString, getProps --- it('returns query string configuration', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); expect($component->getQueryString())->toBe(['count']); }); it('returns props configuration', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); expect($component->getProps())->toBe(['count']); }); // --- getVariables, getInitialAttributes --- it('returns variables after boot', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->boot(['count' => 5], []); expect($component->getVariables())->toBe(['count' => 5]); }); it('returns initial attributes after boot', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $component->boot([], ['class' => 'my-class']); expect($component->getInitialAttributes())->toBe(['class' => 'my-class']); }); // --- Redirect trait --- it('stores redirect URL', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); $result = $component->redirect('/some-page'); expect($result)->toBe($component); expect($component->redirectTo)->toBe('/some-page'); }); it('redirect defaults to null', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); expect($component->redirectTo)->toBeNull(); }); // --- Dynamic properties --- it('returns empty array for getDynamicProperties by default', function () { $component = makeComponent(Tests\App\Yoyo\Counter::class); expect($component->getDynamicProperties())->toBe([]); }); ================================================ FILE: tests/Unit/ConfigurationTest.php ================================================ toBe('Tests\\App\\Yoyo\\'); }); it('returns default when key not found', function () { expect(Configuration::get('nonexistent', 'fallback'))->toBe('fallback'); }); it('returns null when key not found and no default', function () { expect(Configuration::get('nonexistent'))->toBeNull(); }); it('provides default config values', function () { expect(Configuration::get('defaultSwapStyle'))->toBe('outerHTML'); expect(Configuration::get('historyEnabled'))->toBeFalse(); expect(Configuration::get('indicatorClass'))->toBe('yoyo-indicator'); expect(Configuration::get('requestClass'))->toBe('yoyo-request'); expect(Configuration::get('settlingClass'))->toBe('yoyo-settling'); expect(Configuration::get('swappingClass'))->toBe('yoyo-swapping'); }); it('generates htmx source URL with default version', function () { $src = Configuration::htmxSrc(); expect($src)->toContain('htmx.org@'); expect($src)->toContain('/dist/htmx.min.js'); }); it('generates yoyo source path with default config', function () { $src = Configuration::yoyoSrc(); expect($src)->toContain('yoyo.js'); }); it('generates JavaScript assets with script tags', function () { $assets = Configuration::javascriptAssets(); expect($assets)->toContain('']); // Should escape quotes, tags, ampersands expect($result)->not->toContain('