Skip to content

Retire the last server-rendered views, shrink Globals, and unify the PHP→JS bridge - #297

Closed
HugoFara wants to merge 9 commits into
developfrom
refactor/client-side-views-and-globals
Closed

Retire the last server-rendered views, shrink Globals, and unify the PHP→JS bridge#297
HugoFara wants to merge 9 commits into
developfrom
refactor/client-side-views-and-globals

Conversation

@HugoFara

Copy link
Copy Markdown
Owner

Started as an audit — can more PHP views become pure frontend, and how is global
state passed around — and turned into the cleanup that audit called for.

The short answer to the first question was "less than you'd think": of 65 views,
only six still contained a server-side foreach, and all six are <option>
lists. The data screens had already moved. What was left was dead code, a
Globals class where half the members did nothing, and a PHP-to-JS bridge that
had drifted apart at 35 separate call sites.

Dead code

  • TextReadingService (333 lines) echoed the reading pane word span by word
    span. No callers; text_renderer.ts took the job over. The only surviving
    mention was a comment pointing back at it.
  • Review's header.php, header_content.php, footer.php are reachable
    only via ReviewController::header(), which no route registers. Removing the
    method takes five tests with it — four asserted nothing beyond
    assertTrue(true) around an include expected to fail.
  • PageLayoutHelper::renderFramesetHeader() — no callers, no framesets.

Globals — 505 lines down to 353

  • table() was the identity function. return $tableName;, left from a
    configurable table prefix that no longer exists. It read as though it
    transformed the name, and HomeFacade even had a comment claiming it returned
    "properly prefixed" names. Its 185 call sites are now plain strings.
  • query() forwarded one line to QueryBuilder::table().
  • The error-display flag was set to false by initialize() and never set
    true; its single reader passed it straight through. initialize() then had
    nothing left to do.
  • The mysqli handle lived in two placesConnection::$instance and
    Globals::$dbConnection — with getInstance() resyncing from the second on
    every call. Connection now owns it alone. The Globals accessors stay
    because 291 call sites use them as an "is the database up?" probe; the
    duplicated storage was the problem, not the name.

One behaviour change falls out: Connection::reset() now genuinely clears the
handle. testResetClearsInstance was asserting the opposite of its own name.

What's left is documented as what it is: request-scoped user context that
QueryBuilder reads to scope every query, so a repository that looks
unscoped is filtered. That is a security boundary, not a setting.

Config islands

Server values reach a page as a JSON island because the CSP build of Alpine
cannot evaluate inline expressions. The pattern was sound; the plumbing was not
— 35 islands each hand-rolled their own escaping flags, and eight pages
hand-rolled their own reader. Now one ConfigIsland emitter and one
readPageConfig reader. That fixes:

  • Six islands emitted json_encode with no escaping flags at all, so a
    value containing </script> closed the element early.
  • preferences.php hand-spliced a pre-encoded value into a literal
    {"currentLanguageCode": …}, valid only while the controller remembered to
    encode it.
  • starter_vocab.ts parsed without a try/catch, so a malformed blob
    threw during init() and left an inert shell.

JsonScriptBlockEscapingTest now pins a stronger invariant than the old flag
scan: one test feeds the emitter a payload that tries to close its own script
element and checks the output; the other asserts no file outside ConfigIsland
emits a config block, so a new view cannot quietly reintroduce the old form.

The one real conversion

/text/{id}/display was the last page building content in PHP. Three views
collapse into one shell that fetches GET /api/v1/texts/{id}/annotation — the
endpoint the print view already used. The generated markup is deliberately
identical, because annotation_toggle.ts finds terms by the .anntermruby /
.anntransruby2 classes at click time and anything else would break the
show/hide buttons silently.

That endpoint had two gaps, fixed first:

  • No romanization on items. Added, resolved for the whole text in one query
    — a lookup per term is hundreds of round trips on a normal text.
  • Blank rows came back as words. Splitting the stored annotation on newlines
    yields an entry for any trailing newline, and those were reported as terms.
    The annotated print view has been rendering two empty <ruby> elements per
    text as a result
    — a pre-existing bug, not one introduced here.

Prev/next navigation stays server-rendered: it reads the language filter, query
and tag selection out of the session, none of which has an endpoint.

E2E: three failures that were not application bugs

Two specs reported that text creation "did not redirect"; a third failed with
ESOCKETTIMEDOUT. None of that was true. The POST returned 200, the redirect
was issued, the browser was already fetching it — the request was queued and
landed after Cypress's 10 s timeout. A page taking 11 ms on an idle server took
28.5 s while difficulty previews were in flight.

Two causes:

  • Uncached book downloads. The suggestion panels request a difficulty
    preview for every book listed, and each downloaded the whole book from
    Gutenberg — 5.4 s, essentially all network — with no cache, on every page
    load. A FileCache now holds the fetched document: 5.4 s cold, 0.02 s
    warm
    . Only the document is shared; coverage is still computed per user, so
    the figures stay personal. This helps production, not just tests.
  • A single-request dev server. php -S serves one request at a time unless
    PHP_CLI_SERVER_WORKERS is set, which is what turned one slow request into a
    suite-wide stall. Added npm run serve as the one way to start it, documented
    in CLAUDE.md and both copies of the contributing guide.

I verified each half alone: caching alone still failed, workers alone passed.
Both are needed — the cache for the real cost, workers so one slow request
cannot starve everything.

Full suite: 304/304 passing, 23/23 specs, 3:03 from a cold cache. Previously
4 failures in 6:49 (99-screenshots also failed and now clears).

Docs

CLAUDE.md described a codebase that no longer exists: src/backend/Views,
Services and Router, getSettingWithDefault(), global $var,
Globals::table(), the archivedtexts and textitems2 tables, and a frontend
laid out as pgm.ts / text_events.ts. Corrected, plus the missing Activity
and Book modules and a new Config Islands section.

Also normalised 530 file docblocks that claimed PHP version 8.1 while
composer.json has required ^8.2 and CI has tested 8.2–8.5 for some time.

Verification

Psalm 0 errors · PHPCS 0 errors · 9037 PHP tests · 4359 frontend tests ·
304 E2E · typecheck and lint clean · assets build.

The display conversion was checked against both annotated texts in a real
database rather than trusting the tests: same paragraph count, same term count,
same line heights, toggles still working.

Reviewing this

The nine commits are meant to be read in order and are independently sensible;
the 530-file docblock commit is mechanical and worth skipping. The two changes
that most deserve a second opinion are Connection::reset() now actually
resetting, and keeping the Globals::get/setDbConnection() accessors rather
than rewriting 291 call sites.

Every file header claimed "PHP version 8.1" while composer.json has required
^8.2 and CI has tested 8.2-8.5 for some time. The headers were the only thing
still naming 8.1, so they were actively misleading anyone reading for the
supported baseline.

Mechanical: the docblock line only, in the files where it is the sole change.
Three leftovers from before the reading and review screens moved to
client-side rendering:

- TextReadingService (333 lines) echoed the reading pane word span by word
  span. It has no callers; text_renderer.ts took the job over. The only
  surviving mention was a comment in that file pointing back at it, which now
  describes the underscore attribute contract on its own terms.
- Review's header.php, header_content.php and footer.php are reachable only
  through ReviewController::header(), and no route registers it. Removing the
  method takes five tests with it, four of which asserted nothing beyond
  assertTrue(true) around an include that was expected to fail.
- PageLayoutHelper::renderFramesetHeader() has no callers; the app has no
  framesets left.
Globals had grown into a grab-bag where only some of the contents did
anything. Four things came out:

- table() was the identity function — `return $tableName;` — left over from
  a configurable table prefix that no longer exists. It read as though it
  transformed the name, and HomeFacade even carried a comment claiming it
  returned "properly prefixed" names. Its 185 call sites are now plain
  strings, and the literal concatenations that produced ('DELETE FROM ' .
  'words') are folded into single literals.
- query() forwarded one line to QueryBuilder::table(). Callers now say so.
- The error-display flag was set to false by initialize() and never set
  true by anything; its single reader passed it straight to
  setErrorReporting(). That reader now passes false outright, which is what
  it always got. initialize() had nothing left to do and is gone.
- The mysqli handle lived in both Connection::$instance and
  Globals::$dbConnection, with getInstance() resyncing from the second on
  every call and setInstance() writing both. Connection now owns it alone
  and Globals::get/setDbConnection() delegate. The accessors stay because
  291 call sites use them as a "is the database up?" probe; the duplicated
  storage was the problem, not the name.

One behaviour change falls out: Connection::reset() now genuinely clears the
handle instead of leaving a copy for the next getInstance() to recover.
testResetClearsInstance was asserting the opposite of its own name, so it now
asserts the clear and restores the shared connection for the rest of the run.

Also drops 29 Globals imports left unused by the above, and documents on the
class that the user context is a security boundary QueryBuilder depends on
rather than an ordinary setting.
…one reader

The CSP build of Alpine cannot evaluate inline expressions, so server values
reach a page as a JSON island rather than through x-data. That pattern was
sound; the plumbing around it was not. Thirty-five islands each hand-rolled
their own script tag and escaping flags, and eight pages hand-rolled their own
reader, so the two ends of each contract could drift with nothing to catch it.

Adds ConfigIsland (PHP) and readPageConfig/hasPageConfig (TypeScript), and
routes all of it through them. What that fixes concretely:

- Six islands emitted json_encode with no escaping flags at all, so a value
  containing </script> would close the element early and the rest of the blob
  would parse as markup.
- preferences.php hand-spliced a pre-JSON-encoded value into a literal
  {"currentLanguageCode": ...}, which is only valid as long as the controller
  remembers to encode it. It now passes the plain string and the controller
  stops encoding.
- starter_vocab.ts parsed without a try/catch, so a malformed blob threw
  during init() and left the page an inert shell. readPageConfig never throws:
  missing element, empty blob and bad JSON all fall back to the caller's
  defaults, merged shallowly with null treated as absent.

JsonScriptBlockEscapingTest now pins a stronger invariant than the old flag
scan. One test feeds the emitter a payload that tries to close its own script
element and checks the rendered output; the other asserts no file outside
ConfigIsland emits a config block itself, so a new view cannot quietly
reintroduce the hand-rolled form.
The guide still routed work to src/backend/Views, src/backend/Services and
src/backend/Router, none of which exist — what remains under src/backend is
the REST API and the entry point. It also named getSettingWithDefault(),
Globals::table(), `global $var`, the archivedtexts and textitems2 tables, and
a frontend laid out as pgm.ts / text_events.ts / audio_controller.ts. All of
those are gone; archived texts are a TxArchivedAt column on texts and
textitems2 is word_occurrences.

Corrects the request flow, directory tree, table list and frontend layout to
match, adds the Activity and Book modules, and states the 8.2 docblock floor.

Two additions rather than corrections: the request-context section now says
outright that QueryBuilder reads the user context to scope every query, so a
repository that looks unscoped is filtered and clearing the context is a
security event; and a new Config Islands section documents ConfigIsland and
readPageConfig as the only sanctioned way to move values from PHP to a page.
Two gaps in GET /texts/{id}/annotation, both found while moving the
annotated display view onto it:

- Items carried no romanization, so a client could only get one by querying
  per word. Added, resolved for the whole text in a single query rather than
  one lookup per term — the annotated view renders every word at once, so the
  naive form would be hundreds of round trips on a normal text.
- Blank rows came back as words with empty text. Splitting the stored
  annotation on newlines yields an entry for any trailing or repeated newline,
  and the endpoint reported those as terms; the annotated print view has been
  rendering two empty <ruby> elements per text as a result. They are now
  skipped, matching the rule TextDisplayService::parseAnnotationItem() has
  always applied.
/text/{id}/display was the last page still building content in PHP: it
parsed the stored annotation and echoed a <ruby> per term. It now ships a
shell and fetches GET /api/v1/texts/{id}/annotation, which the print view
already used, so display_main.php, display_header.php and display_text.php
collapse into one display_alpine.php.

The generated markup is deliberately identical to what PHP emitted —
.anntermruby for the term inside <rb>, .anntransruby2 for the translation
inside <rt>, romanization as a title attribute, and a paragraph marker
closing and reopening <p> at the language's text size with the same 1.35
then 1.3 line heights. annotation_toggle.ts drives the show/hide buttons by
querying those classes at click time, so anything else would break them
silently. Verified against both annotated texts in the dev database: same
paragraph count, same term count, same line heights, toggles still working.

Prev/next navigation stays server-rendered. It reads the request's language
filter, search query and tag selection out of the session, none of which has
an endpoint, and it is chrome rather than content.
The suggestion panels on /texts/new and the home page request a difficulty
preview for every book they list, and each preview downloaded the whole book
again: one call measured at 5.4 s, all of it the fetch, against milliseconds
for the tokenizing and vocabulary lookup that follow. Nothing was cached, so
opening the page refetched the same handful of books every time and pinned a
PHP worker per download for the duration.

Adds a small file-backed FileCache and puts the fetched source document behind
it, keyed by URL with a 24 h TTL matching the Gutenberg suggestion cache. Same
call now: 5.4 s cold, 0.02 s warm. Only the document is shared — coverage and
difficulty are still computed per user on top of it, so the figures stay
personal.

FileCache degrades to a miss on every failure it can hit: an unwritable temp
directory, an unreadable entry, an expired one. The cost of a miss is a
refetch, and a cache that can throw on a page load would be worse than no
cache at all. Writes go to a temp file and rename, so a reader never sees a
half-written entry and two writers cannot interleave.
Three specs in 05-texts.cy.ts failed, and the symptoms pointed at the
application: text creation "did not redirect", and /text/edit failed to load
with ESOCKETTIMEDOUT. Neither was true. The POST succeeded, the redirect to
/text/{id}/read was issued, and the browser was already requesting it — the
request was simply queued behind other work and arrived after Cypress's 10 s
assertion timeout. Measured on the documented setup: a page that takes 11 ms
on an idle server took 28.5 s while difficulty previews were in flight.

The cause is `php -S`, which serves one request at a time unless
PHP_CLI_SERVER_WORKERS is set. Any slow request — a text import, an outbound
fetch — stalls the whole suite behind it, and the failure surfaces somewhere
unrelated. Adds `npm run serve` as the one way to start the dev server, with
workers, and points CLAUDE.md and both copies of the contributing guide at it
with the reason.

With that and the source-document cache, the full suite goes from 4 failures
in 6:49 to 304 passing in 3:03 from a cold cache.
@HugoFara

Copy link
Copy Markdown
Owner Author

Closing in favour of a reviewable stack — 592 files in one PR was not a fair thing to ask anyone to read.

Same nine commits, unchanged, split into six PRs that each do one thing and stack in order:

  1. #TBD1 chore/php-82-docblocks — 467 files, one mechanical substitution
  2. #TBD2 refactor/remove-dead-server-render — 9 files
  3. #TBD3 refactor/shrink-globals — 59 files
  4. #TBD4 refactor/config-islands — 46 files
  5. #TBD5 refactor/client-side-annotated-display — 12 files
  6. #TBD6 test/e2e-reliability — 7 files

Each is based on the one before it, so its diff shows only its own change. Merge in order and each base retargets to develop automatically.

@HugoFara HugoFara closed this Aug 30, 2026
@HugoFara

Copy link
Copy Markdown
Owner Author

Stack is up, with the real numbers:

  1. chore: state the real PHP floor in file docblocks #298 chore/php-82-docblocks — 467 files, one mechanical substitution
  2. refactor: delete the server-side rendering paths nothing reaches #299 refactor/remove-dead-server-render — 9 files, -802 lines
  3. refactor(globals): shrink the context to what actually carries state #300 refactor/shrink-globals — 59 files
  4. refactor(ui): put every PHP-to-JS config blob behind one emitter and one reader #301 refactor/config-islands — 46 files
  5. refactor(text): render the annotated display view client-side #302 refactor/client-side-annotated-display — 12 files
  6. perf/test: cache library previews and serve E2E with workers #303 test/e2e-reliability — 7 files

Each is based on the one before it, so its diff shows only its own change. Merge in order and GitHub retargets each base to develop as the one below it lands. The nine commits are unchanged from this PR — the branches are truncation points of the same history, and the tip tree is byte-identical to what was tested here.

@HugoFara
HugoFara deleted the refactor/client-side-views-and-globals branch August 30, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant