A page of a site as a React component and its props, the same from any framework (plan 0, rule 13). Symfony and Laravel give the page alike; React renders it.
- PHP (
Omnibridge\React, framework-free):Page(component, props, url, version) andProtocol::answer(). The first visit is an HTML shell holding the page (data-pageon the root element); a visit within the site (headerX-Omnibridge) gets the page in JSON; a front end of another version reloads (409,X-Omnibridge-Location). The application turns the answer into its own response, its shell around the root element. - JavaScript (
js/,@omnibridge/react, plain ES modules):createApp()renders the page of the shell,Linkand the links of the page under the prefix are followed without reloading (visit()), back and forward restore the pages seen,fetchPage()asks a page without showing it (a feed's next batch),usePage()reads the page shown.
The JavaScript is installed from this repository, as Composer installs the PHP: the package.json at
its root is @omnibridge/react (its files under js/src), at a commit or a tag of the repository -
"dependencies": { "@omnibridge/react": "github:glitchr-omnisense/omnibridge-react#<commit>" }
lesringostars uses it for its blog and its articles, given by its Symfony site and by its Laravel copy
from the same screens (lesringostars-react). omnisense/omnibase's Base\Screen\Negotiation answers
its screens with this protocol when the package is installed, under Symfony
(Base\Bridge\Symfony\ScreenResponder) and under Laravel (omnibridge/laravel's Screen\Responder).
How this manual is organised
- Installation: the PHP package, the JavaScript package, a page answered three ways (run, with its output).
- The protocol:
Page,Protocol, every header and status, the answer in a Symfony or Laravel response. - The JavaScript:
createApp(),Link,visit(),fetchPage(),usePage(), which links are followed, the history. - Testing, Verified, and not.
Licence
MIT.
Propose a change docs/index.md
Installation
The PHP package
composer require omnibridge/react
PHP 8.2 or later; nothing else (no framework, no PSR interface).
The JavaScript package
{
"dependencies": {
"@omnibridge/react": "github:glitchr-omnisense/omnibridge-react#<commit>",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
package.json at the repository's root: @omnibridge/react 0.1.0, an ES module ("type": "module"),
main and exports at js/src/index.js, the types at js/src/index.d.ts, the files js/src only.
React and React DOM 18 or later are peer dependencies: the application installs them. No build step:
the bundler of the application (webpack Encore, Vite...) takes the module as it is.
A page answered three ways
<?php
require 'vendor/autoload.php';
use Omnibridge\React\Page;
use Omnibridge\React\Protocol;
$page = new Page('Article', ['title' => 'Le Fuji « 富士山 »'], '/articles/le-fuji', 'build-42');
$shell = static fn (string $root): string => "<!doctype html><html><body>$root<script type=\"module\" src=\"/build/app.js\"></script></body></html>";
foreach ([[null, null], ['true', 'build-42'], ['true', 'build-41']] as [$header, $version]) {
$answer = Protocol::answer($page, $header, $version, $shell);
echo 'X-Omnibridge: ', $header ?? '(none)', ', X-Omnibridge-Version: ', $version ?? '(none)', "\n";
echo ' ', $answer['status'], ' ', json_encode($answer['headers'], JSON_UNESCAPED_SLASHES), "\n";
echo ' ', $answer['body'], "\n";
}
Its output (PHP 8.4.27, 2026-10-27):
X-Omnibridge: (none), X-Omnibridge-Version: (none)
200 {"Content-Type":"text/html; charset=UTF-8","Vary":"X-Omnibridge"}
<!doctype html><html><body><div id="app" data-page="{"component":"Article","props":{"title":"Le Fuji « 富士山 »"},"url":"/articles/le-fuji","version":"build-42"}"></div><script type="module" src="/build/app.js"></script></body></html>
X-Omnibridge: true, X-Omnibridge-Version: build-42
200 {"Content-Type":"application/json","X-Omnibridge":"true","Vary":"X-Omnibridge","X-Omnibridge-Version":"build-42"}
{"component":"Article","props":{"title":"Le Fuji « 富士山 »"},"url":"/articles/le-fuji","version":"build-42"}
X-Omnibridge: true, X-Omnibridge-Version: build-41
409 {"X-Omnibridge-Location":"/articles/le-fuji","Vary":"X-Omnibridge"}
- A first visit (no header): the application's shell around the root element, the page in its
data-pageattribute. - A visit within the site, same version: the page in JSON.
- A front end built for another version: 409, the address to reload whole, no body.
And the front end that renders it:
// assets/app.js
import { createApp } from '@omnibridge/react';
import Article from './pages/Article.jsx';
import Blog from './pages/Blog.jsx';
const pages = { Article, Blog };
createApp({ resolve: (name) => pages[name] });
Propose a change docs/installation.md
The protocol
Page
final class Page implements \JsonSerializable
{
public function __construct(
public readonly string $component, // the React component's name, which the front end resolves
public readonly array $props, // plain data: the component's props
public readonly string $url, // the page's address, put in the browser's history
public readonly ?string $version = null, // the front end's version (a build's hash), null for none
) {}
}
jsonSerialize() is {component, props, url, version}, in that order.
Protocol
| Constant | Value | Meaning |
|---|---|---|
HEADER |
X-Omnibridge |
sent by the front end on a visit within the site; answered true on a JSON page |
VERSION |
X-Omnibridge-Version |
sent by the front end with the version it was built as; answered with the page's |
LOCATION |
X-Omnibridge-Location |
on a 409: the address to load whole |
| Static method | Returns |
|---|---|
wantsJson(?string $header): bool |
true for any value of X-Omnibridge but none, '' and false (any case) |
answer(Page $page, ?string $header, ?string $clientVersion, \Closure $shell): array |
{status, headers, body}, as below |
json(Page $page): string |
the page in JSON, Unicode and slashes unescaped; \JsonException for props JSON cannot encode |
root(Page $page, string $id = 'app'): string |
<div id="app" data-page="..."></div>, the JSON escaped for an attribute |
answer():
| Request | Status | Headers | Body |
|---|---|---|---|
no X-Omnibridge (a first visit, a reload) |
200 | Content-Type: text/html; charset=UTF-8, Vary: X-Omnibridge |
$shell(Protocol::root($page)): the application's HTML around the root element |
X-Omnibridge, and the page has no version, or the client sent none, or the same |
200 | Content-Type: application/json, X-Omnibridge: true, Vary: X-Omnibridge, X-Omnibridge-Version (when the page has a version) |
Protocol::json($page) |
X-Omnibridge and another version than the page's |
409 | X-Omnibridge-Location: <page url>, Vary: X-Omnibridge |
'' |
Vary: X-Omnibridge on every answer: a cache keeps the shell and the JSON of one address apart.
The protocol says nothing of caching otherwise, nor of a visitor's privacy: omnibase's
Base\Screen\Negotiation adds Vary: Cookie and private for a page that carries a visitor.
The answer in a response
Under Symfony:
use Omnibridge\React\Page;
use Omnibridge\React\Protocol;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
public function article(Request $request, string $slug): Response
{
$page = new Page('Article', ['slug' => $slug, 'title' => '...'], $request->getRequestUri(), $this->version);
$answer = Protocol::answer(
$page,
$request->headers->get(Protocol::HEADER),
$request->headers->get(Protocol::VERSION),
fn (string $root) => $this->renderView('react.html.twig', ['root' => $root]),
);
return new Response($answer['body'], $answer['status'], $answer['headers']);
}
{# templates/react.html.twig #}
<!doctype html>
<html>
<head>{{ encore_entry_link_tags('app') }}{{ encore_entry_script_tags('app') }}</head>
<body>{{ root|raw }}</body>
</html>
Under Laravel, the same with $request->header(Protocol::HEADER) and
response($answer['body'], $answer['status'], $answer['headers']). The version is the application's
choice: a hash of its build's manifest, changed at each deployment, so a front end left open in a tab
reloads the next page whole instead of rendering it with stale components. These two snippets show
the wiring; they were not run here (the page's own script was, Installation).
Propose a change docs/protocol.md
The JavaScript
js/src/index.js, an ES module importing react (createContext, createElement, useContext,
useEffect, useState), react-dom (flushSync) and react-dom/client (createRoot); its types
in js/src/index.d.ts.
createApp
createApp(options: {
resolve: (name: string) => ComponentType<any> | undefined; // the component of a page's name
el?: HTMLElement | null; // default document.getElementById('app')
prefix?: string; // default '/': the links followed within the front end
}): void
- Reads the page from the root element's
data-page(the shell's). - Replaces the history's current entry with
{omnibridge: page}at the page'surl. - Listens to clicks inside the root element: a plain
<a href>of the page's own HTML (an article's text) is followed within the front end when it may be. - Renders the page at once (
flushSync), before the page's other deferred scripts run, so a site's own behaviours loaded after the front end (a drawer, a sticky header) find their elements in place.
The page's component is resolve(page.component), rendered with page.props, inside a context that
usePage() reads. A name resolve does not know throws Error('omnibridge/react: no component "<name>"')
while rendering.
Link
import { Link } from '@omnibridge/react';
<Link href="/articles/le-fuji" className="card">Le Fuji</Link>
<Link href="/articles?page=2" replace preserveScroll>Next</Link>
An <a> with every attribute given; on a click that may be followed and
that the onClick given did not prevent, visit(href, {replace, preserveScroll}) instead of the
browser's navigation.
visit
visit(url: string, options?: { replace?: boolean; preserveScroll?: boolean }): Promise<Page>
Asks the page (fetchPage()), pushes it in the history at its url (or replaces the current entry),
shows it, then - unless preserveScroll - scrolls to the element of the URL's fragment, else to the top.
fetchPage
fetchPage(url: string): Promise<Page>
A fetch() with X-Omnibridge: true, X-Omnibridge-Version (the version of the page shown, ''
before any), Accept: application/json, the same-origin credentials. Without showing it: a feed's
next batch is a page whose props the component appends.
- 409: the browser loads
X-Omnibridge-Location(or the URL) whole; the promise never settles. - Not OK, or an answer without
X-Omnibridge(an error page, another application's page): the browser loads the URL whole; the promise never settles. - Otherwise the page's JSON.
usePage
usePage<P>(): Page<P> // { component, props, url, version }
The page shown, from any component under the page's (a layout's header reading the frame's props).
Which links are followed
A click is followed within the front end when all of these hold; otherwise the browser has it:
- the main button, with no modifier key (Ctrl, Cmd, Shift, Alt);
- the anchor has no
targetother than_self, nodownload, and nodata-omnibridge="false"; - its URL is of the same origin and its path starts with
prefix; - it is not a link to a place of the current page (same path and query, with a fragment).
Back and forward
Each page shown is kept in the history's state ({omnibridge: page}). On popstate, the state's page
is shown again as it was, without asking the server; an entry without one (made outside the front
end) is asked again with visit(location.href, {replace: true}).
The types
export interface Page<P = Record<string, unknown>> {
component: string;
props: P;
url: string;
version: string | null;
}
usePage, fetchPage and visit are generic over the props.
Propose a change docs/javascript.md
Testing
Testing an application
Protocol::answer() is a function of its arguments: a controller's test asks the page three ways -
no header (the shell, data-page holding the page), X-Omnibridge: true (the JSON), another
X-Omnibridge-Version (409) - and compares, as the package's own tests do. The JSON a host gives for
a screen is compared between hosts: lesringostars' parity suite checks that its Symfony site and its
Laravel copy give the same pages to lesringostars-react.
The package's suite
docker run --rm -v "$PWD":/app -w /app lesringostars-laravel-app vendor/bin/phpunit tests
ProtocolTest: the first visit is the shell holding the page (the JSON in data-page decodes to the
page); a visit within the site is the page in JSON (its version header, Vary, Unicode as it is); a
front end of another version reloads (409 and the location).
The JavaScript has no test in this package: it is exercised by lesringostars-react (its six tests and its build). The CI (GitHub Actions, glitchr-studio/ci's package workflow) runs PHPUnit and PHPStan.
Propose a change docs/testing.md
Verified, and not
What the tests prove
Run on 2026-10-27 in lesringostars-laravel-app (PHP 8.4.27), the checkout's own vendor/, no
network: 3 tests, 11 assertions, green. The Installation page's script was run in
the same image that day and its output pasted.
Verified elsewhere
- lesringostars' blog and articles through lesringostars-react, given by the Symfony site and by its Laravel copy from the same screens (its six tests, its build, the parity suite).
createApp()rendering at once (flushSync, commit 2547b2a): found on lesringostars, whose layout's scripts loaded after the front end did not find their elements.
What is not verified
- The JavaScript in this repository: no test of its own, and not run for this manual (no Node.js
harness here); what The JavaScript says is read from
js/src/index.js. - React 18: the peer dependency admits it; lesringostars-react's version is the one exercised.
- The Symfony and Laravel snippets of The protocol: written against the classes' signatures, not run.