What's new in Viewi 2.8 and 2.9

Viewi had three releases this month: 2.8.1 on 9 September, 2.8.2 on the 21st and 2.9.1 a day later. Most of it came out of Urlicer, the product I'm building on Viewi. Something broke there or needed a workaround, and the fix went into the framework instead of the app.

PHP functions now behave the same in the browser

A Viewi component runs twice. PHP renders it on the server, then the browser runs the transpiled JavaScript version of the same class. Every PHP function the component calls needs a JavaScript port. Until this release nothing checked those ports against PHP. I wrote each one, looked at the output and moved on.

That caught up with me on Urlicer's bulk link import. The page checks every pasted line with preg_match(...) === 1. The server-rendered page was correct. In the browser the port returned the boolean from RegExp.test(), so === 1 was never true. The moment the page hydrated, every row turned invalid and the submit button stayed disabled. No error anywhere.

So 2.9 comes with a parity suite. For every ported function, the tests call PHP and the JavaScript version with the same arguments and compare the results, types included. An int 1 and a true count as different answers. The suite has 998 tests and runs with composer test. It needs Node.

The status of every function is in FUNCTIONS.md, generated from the source and the tests. The tests fail if the file is out of date, so it can't drift from the code. Right now it lists 324 functions:

  • 222 verified: every test case matches PHP.
  • 58 with a known difference, each documented with a workaround.
  • 18 server-only. Calling one from a component fails the build.
  • 26 internal helpers.

What it found in the transpiler

Writing test cases for functions kept turning up bugs one level lower, in how the transpiler emits ordinary PHP operators. All of these are fixed in 2.8.2 and 2.9.1:

  • Concatenation. 'Active: ' . true is "Active: 1" in PHP and came out as "Active: true" in the browser. 1 . 2 is "12", and JavaScript's + made it 3.
  • isset($row['key']) was emitted as 'key' in row. That is true for a key holding null, where PHP says false, and it throws when $row is null. It now becomes an optional chain with a null check, which is what PHP's isset() promises.
  • Casts, <=> and empty() follow PHP's rules.
  • By-reference array arguments work: preg_match() fills $matches and sort() sorts in place.
  • A float literal such as 0.5 in a component method stopped the build with "Node type 'Scalar_Float' is not handled", so fractions had to be written as integer arithmetic.
  • Built-in constants like PHP_EOL and PHP_INT_MAX are replaced with their values. A global constant from define() fails the build. Use a class constant instead.

Server-only functions fail the build

Some functions mean nothing in a browser. Call one from a component and the build stops with the reason:

file_get_contents() is server-only: there is no filesystem in a browser. Call it on the server
(a service or controller) and pass the result to the component.

That covers the filesystem, the shell, getenv, ini_set, setcookie and the locale functions. A function with no port at all fails the same way:

Function 'mb_substr' can not be found or is used outside of your source paths.

What can't be the same

Some differences come from the platform, and no port can hide them. The ones most apps will meet:

  • PHP counts bytes, JavaScript counts characters. strlen('héllo') is 6 on the server and 5 in the browser. Use mb_strlen() for text people read.
  • A PHP array with integer keys that isn't a list becomes a JavaScript object, and JavaScript sorts integer-like keys. [5 => 'x', 2 => 'y'] arrives as {"2": "y", "5": "x"}. Keep ordered data as a list of records.
  • PHP formats dates in the server's time zone, the browser in the visitor's. The same timestamp can show a different day after hydration. Format on the server, or use gmdate().

The full list, with examples, is on the PHP functions in the browser page.

Event modifiers

Handling Enter in an input used to mean a method that takes the event, checks the key and returns early. Now the template says it:

<input model="$draft" (keyup.enter)="add" (keyup.esc)="$draft = ''" />
<form (submit.prevent)="save">...</form>
<textarea (keydown.ctrl.enter.exact)="send"></textarea>

Key modifiers match on event.key. ctrl, shift, alt and meta require that key to be held, and exact rejects any other, so the last line ignores Ctrl + Shift + Enter. prevent and stop only apply when the key checks pass: (keydown.enter.prevent) blocks Enter and lets the rest of the typing through. once, passive and capture go to addEventListener as listener options.

A typo like (keyup.entr) fails the build and lists the valid modifiers. Otherwise it would be a handler that silently never runs.

In Urlicer, the quick-add field on the links page creates a link on (keydown.enter). The SearchInput in Viewi UI clears itself on (keydown.escape).

DomEvent also got the fields the modifiers are built on: key, code, ctrlKey, shiftKey, altKey, metaKey and repeat. There's dataTransfer for drag and drop too, which the new row reordering in the Viewi UI DataTable uses. One thing I learned there: Firefox won't start a drag unless dragstart calls setData().

Details and live examples: Event handling.

Shipped in the summer, documented now

A few features went out in 2.7 and 2.8.0 with no documentation. They have pages now.

Guards take arguments

Write the guard as an array, class first, then the arguments:

#[Middleware([[HasPermission::class, 'EditPosts']])]
class EditPostPage extends BaseComponent
{
}

The arguments fill the guard's constructor parameters by position. Services are still injected as usual. Route parameters arrive the same way, by name:

// $router->get('/post/{id}/edit', EditPostPage::class);

class CanEditPost implements IMIddleware
{
    public function __construct(private int $id, private PostService $posts)
    {
    }

    public function run(IMIddlewareContext $c)
    {
        $c->next($this->posts->canEdit($this->id));
    }
}

One guard class can now cover every page that differs only by a permission name. Don't mark it #[Singleton]. A singleton is cached by class name, and every page would get the first page's arguments.

Changing the URL without navigating

ClientRoute::replaceUrl() updates the address bar with no render and no new history entry. Urlicer's analytics page writes its filters and date range into the query string on every change, so a reload or a shared link opens the same view. navigate() would render the page again and push a history entry per click.

New asset URLs on every build

versionSubFolder() in AppConfig puts the public build files in a folder named after the build ID and removes the previous one. Every deploy gets new asset URLs, so no browser keeps a cached bundle from the last release.

A readable error when a component bundle fails to load

If a lazy-loaded group came back as a 404 or a 500, you got a JSON parse error. Now the error has the HTTP status in it. This one is a pull request from anupamme. Thank you.

Still open

Viewi in production listed four places where the browser silently disagrees with the server. preg_match is fixed. These three are not:

  • A .html template that ends with a newline breaks hydration. End the file at the last >.
  • A bare {{ }} interpolation next to an element in the same parent duplicates that element after a re-render. Wrap the text in its own element.
  • Other::CONST from a class the component doesn't use isn't tracked as a dependency, and the browser throws a ReferenceError. Add the use, even when both classes share a namespace.

All three pass server rendering, which is why they're easy to miss.

To upgrade: composer update viewi/viewi. If a component behaves differently after hydration, look the function up in FUNCTIONS.md first.