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: ' . trueis"Active: 1"in PHP and came out as"Active: true"in the browser.1 . 2is"12", and JavaScript's+made it3. isset($row['key'])was emitted as'key' in row. That is true for a key holdingnull, where PHP says false, and it throws when$rowis null. It now becomes an optional chain with a null check, which is what PHP'sisset()promises.- Casts,
<=>andempty()follow PHP's rules. - By-reference array arguments work:
preg_match()fills$matchesandsort()sorts in place. - A float literal such as
0.5in 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_EOLandPHP_INT_MAXare replaced with their values. A global constant fromdefine()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. Usemb_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
.htmltemplate 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::CONSTfrom a class the component doesn'tuseisn't tracked as a dependency, and the browser throws a ReferenceError. Add theuse, 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.