Aller au contenu

omnibridge/react

A page of a site as a React component and its props, the same from any framework: the first visit an HTML shell holding the page, a visit within the site the page in JSON (header X-Omnibridge), the JavaScript side rendering it and following the site's links without reloading.

Étage
Pont
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibridge/react:1.x-dev

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) and Protocol::answer(). The first visit is an HTML shell holding the page (data-page on the root element); a visit within the site (header X-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, Link and 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.

Proposer une modification 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="{&quot;component&quot;:&quot;Article&quot;,&quot;props&quot;:{&quot;title&quot;:&quot;Le Fuji « 富士山 »&quot;},&quot;url&quot;:&quot;/articles/le-fuji&quot;,&quot;version&quot;:&quot;build-42&quot;}"></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-page attribute.
  • 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] });

Proposer une modification 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).

Proposer une modification 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
  1. Reads the page from the root element's data-page (the shell's).
  2. Replaces the history's current entry with {omnibridge: page} at the page's url.
  3. 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.
  4. 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.

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).

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 target other than _self, no download, and no data-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.

Proposer une modification 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.

Proposer une modification 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.

Proposer une modification docs/verified.md