Search by

libxa / vite

voukengdongmofrankysteve

Build a single-page app with LibxaFrame controllers and Vite components. No API, no client-side routes.

v0.1.2 2026-08-20 14:54 UTC

This package is auto-updated.

Last update: 2026-08-20 14:54:52 UTC


README

Build a single-page app with LibxaFrame controllers and React components.

No API to design, no client-side route table to keep in step with the server's, no serialization layer. Your controller returns props; a React component renders them. Navigation swaps the page over fetch and updates history — the browser never reloads.

// src/routes/web.php
$router->get('/books', [BookController::class, 'index']);
// src/app/Http/Controllers/BookController.php
public function index()
{
    return libxavite('Books/Index', [
        'books' => Book::all(),
    ]);
}
// src/resources/js/pages/Books/Index.tsx
export default function Index({ books }) {
    return <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}

That is the whole idea. Everything below is detail.

Install

composer require libxa/vite
php libxa vite:install
npm install react react-dom @vitejs/plugin-react vite --save-dev
npm run dev

The client half is not on npm yet. Build it from a checkout of this repository and add it by path:

cd /path/to/libxavite/client && npm install && npm run build
cd /path/to/your-app && npm install file:/path/to/libxavite/client

vite:install writes the entry point, a layout, two example pages, the root Blade view, vite.config.js, tsconfig.json and a middleware you can edit. It never overwrites an existing file unless you pass --force.

Two things are left to you. Register the middleware globally:

// src/bootstrap/app.php
$app->make(HttpKernel::class)->pushMiddleware(App\Http\Middleware\HandleViteRequests::class);

And point a route at a page:

$router->get('/', fn () => libxavite('Home', ['message' => 'It works.']));

The helper is libxavite(), not vite() — the framework already has a vite() for asset tags, and you will use both in the same files.

Hot reload

Run both servers, then browse http://localhost:5173 — not the PHP port. Vite serves the modules and proxies everything else to PHP, so pages, modules, the HMR socket and your session cookie are all one origin.

php libxa serve   # :8000
npm run dev       # :5173
You edit What happens
a component swapped in place, component state kept
a stylesheet new styles applied, no reload
a controller, route or Blade view the page reloads

The third row is not free — Fast Refresh has no idea those files exist. vite.config.js watches src/app, src/routes and src/resources/views and sends a full reload when a .php file under them changes.

Two things make this work, and both are easy to lose:

  • @libxaviteReactRefresh in the root view, before @vite(...). The React plugin normally injects its Fast Refresh preamble by rewriting the HTML it serves — but this HTML comes from Blade, which the plugin never sees. Without the preamble every component throws "@vitejs/plugin-react can't detect preamble". The directive emits nothing in production, so leave it in.
  • src/public/hot, written by the dev server while it is listening and deleted when it stops. It records the port Vite actually bound to, which is not always the configured one — Vite moves to the next free port silently. PHP reads it to decide between dev-server URLs and built files.

Browsing the PHP port directly also works — the asset tags are absolute — but you lose nothing by using the Vite port and it avoids the cross-origin question entirely.

Rendering pages

libxavite($component, $props) returns a full HTML document on a first visit and a JSON page object on every navigation after that. Nothing in your controller changes between the two.

return libxavite('Users/Show', [
    'user' => $user,
    'canEdit' => $request->user()?->can('update', $user),
]);

Props can be closures. A closure is only called if the prop is actually going to be sent, which is what makes partial reloads worth using.

Shared data

Anything every page needs — the authenticated user, flash messages — goes in the middleware:

class HandleViteRequests extends \LibxaVite\Http\Middleware\HandleViteRequests
{
    public function share(Request $request): array
    {
        return [
            'auth' => ['user' => fn () => auth()?->user()],
            'flash' => Vite::always(fn () => session()->pull('flash')),
        ];
    }
}

Validation errors are merged in for you, whatever share() returns — a subclass that forgets parent::share() cannot silently break every form in the application.

Prop types

Four wrappers change when a prop is sent. All are static methods on LibxaVite\Vite.

Sent on a full load Sent on a partial reload
plain value yes only if asked for
Vite::optional(fn) no only if asked for
Vite::always(fn) yes always, unless excluded
Vite::defer(fn) no — announced, then fetched only if asked for
Vite::merge(fn) yes appended, not replaced

optional — expensive data a page usually does not need. The closure never runs unless the client asks by name.

always — flash messages, and anything else that must survive a partial reload. Without it, refreshing one prop would drop the "Saved." notice that arrived with it.

defer — render the page now, fetch this after. The client makes one follow-up request per group, so three slow props declared together cost one round trip:

'stats' => Vite::defer(fn () => $this->expensiveAggregate(), 'dashboard'),
'chart' => Vite::defer(fn () => $this->alsoSlow(), 'dashboard'),

merge — pagination and infinite scroll. Page two returns page two; the client keeps page one.

Client

Navigating

import { Link, router } from '@libxa/vite/react';

<Link href="/books">Books</Link>
<Link href="/books/1" method="delete">Delete</Link>

router.get('/books', { data: { q: term } });
router.post('/books', { title: 'Piranesi' });

Link stays a real anchor for GET, so middle-click, ctrl-click and "open in new tab" keep working. A non-GET link renders as a button instead — an anchor would be GET-ed by a stray middle-click, which for a delete link means the row disappearing.

Visit options: only, except, replace, preserveScroll, preserveState, headers, and the onStart / onSuccess / onError / onFinish callbacks.

Partial reloads

Ask for the props that changed and nothing else:

router.get('/books', {
    data: { q: term },
    only: ['books'],
    replace: true,
    preserveState: true,
});

Only the books closure runs on the server. Every other prop keeps the value the client already holds — including deferred ones, which are not recomputed.

The current page

import { usePage } from '@libxa/vite/react';

const { props, url, component } = usePage<{ auth: { user: User } }>();

Available anywhere in the tree, so a layout six levels down does not need shared data passed to it one component at a time.

Forms

const form = useForm({ email: '', password: '' });

<input value={form.data.email} onChange={(e) => form.setData('email', e.target.value)} />
{form.errors.email && <em>{form.errors.email}</em>}

<button disabled={form.processing} onClick={() => form.post('/login')}>Sign in</button>

Errors arrive as page props from the controller's own validation. There is no schema on this side to fall out of step with the server's rules.

Also on the returned object: isDirty, recentlySuccessful, reset(...fields), clearErrors(...fields), setError, transform, setDefaults, cancel.

Layouts

Books.layout = Main;              // one
Books.layout = [Shell, Sidebar];  // nested, outermost first

A layout is a component taking children, and it is mounted once — an open menu, a scroll position, a playing video all survive navigation.

Titles

import { Head, setTitleTemplate } from '@libxa/vite/react';

setTitleTemplate((title) => `${title} — Acme`);

<Head title="Books" />

Because navigation never reloads the document, nothing resets the head between pages. Head removes what it added when the page unmounts.

How it works

The client sends X-Libxa-Vite: true. The server answers with JSON instead of HTML:

{
    "component": "Books/Index",
    "props": { "books": [] },
    "url": "/books",
    "version": "e488ccf7"
}

Everything else falls out of that:

  • First visit — no header, so the root Blade view renders with the page object in data-page. The client reads it and mounts.
  • Redirects after PUT, PATCH or DELETE become 303. A 302 tells the browser to repeat the request at the new location with the same method, so a redirect after a DELETE becomes a DELETE of the page you redirected to.
  • Version mismatch — the client sends the build it is running. If the server has since been redeployed, it answers 409 with X-Libxa-Vite-Location and the client does a real navigation, because the JavaScript it is running may not be able to render the new page.
  • External redirects use the same 409, since a fetch cannot follow a redirect off-origin and have the browser end up there.
  • A response without the header — a login page from an auth layer, an error page — triggers a hard navigation. The client cannot render it, and swapping in nothing leaves a blank screen.

Configuration

php libxa vendor:publish --tag=libxavite-config

root_view, pages directory, version, encrypt_history, and the SSR block.

The version defaults to a hash of the built asset manifest, which is almost always what you want: it changes exactly when the client's JavaScript changes.

Stacks

React is supported and tested. Vue, Svelte, Preact and Solid adapters are next; vite:install --stack=<name> will name what exists rather than scaffold something that cannot start.

The router is framework-agnostic — it knows the protocol, history and the DOM, and nothing about components. An adapter is one file.

Testing

composer test   # 61 tests: the protocol, the page object, the scaffolder, the dev server
cd client && npm test   # 42 tests: the router and the React adapter

License

MIT.