PPPHP

Performance & Asset Caching

How PPPHP delivers its module code to the browser, why the Designer used to re–download ~840 KB on every visit, and how to use the same caching for your modules. Also: the measuring habits that stop you from optimising the wrong thing.

Who is this for? Owners and administrators. Hidden from regular users.

Contents


The problem — inlined assets can never be cached

Only the public/ folder is reachable from the web. Everything under modules/ is deliberately not, which protects your controllers and config. That left module views one way to deliver their JavaScript and CSS — print the file into the HTML:

<!-- the old pattern, still used by several modules -->
<style><?php readfile(__DIR__ . '/styles.css'); ?></style>
<script><?php readfile(__DIR__ . '/app.js'); ?></script>

It works, and for a small file it is perfectly fine. The catch is what it costs on a second visit. A browser can only cache something that has its own URL. Code printed inside the HTML has no URL of its own, so it is part of the page — and the page is re-sent every single time.

What this looked like in the Designer. Nine JavaScript files plus one stylesheet, about 840 KB, pasted into the document. Opening the Designer ten times downloaded that 840 KB ten times — even though not one byte had changed.

The solution — ModuleAssets

core/ModuleAssets.php gives module files a real, public, versioned URL without moving them out of the module:

/module-asset/Designer/Views/app/registry.js?v=4f0dea4479
 └── route      └── module   └── path inside the module   └── content version

Three things make this fast:

Using it in your own module (3 lines)

Say your module is modules/Reports/ with Views/assets/reports.js and Views/assets/reports.css.

1. Import the helper at the top of your view:

use PpPhp\Core\ModuleAssets;

2. In <head>, replace the inlined style:

<!-- before -->
<style><?php readfile(__DIR__ . '/assets/reports.css'); ?></style>

<!-- after -->
<?= ModuleAssets::style('Reports', 'Views/assets/reports.css', $baseUrl) ?>

3. Before </body>, replace the inlined script:

<!-- before -->
<script><?php readfile(__DIR__ . '/assets/reports.js'); ?></script>

<!-- after -->
<?= ModuleAssets::script('Reports', 'Views/assets/reports.js', $baseUrl) ?>

That is the whole change. If you need only the URL — for a preload hint, a web worker, or an image — use:

$url = ModuleAssets::url('Reports', 'Views/assets/logo.svg', $baseUrl);
// /module-asset/Reports/Views/assets/logo.svg?v=1a2b3c4d5e
Load order is preserved. Plain <script src> tags run in the order they appear, exactly as inlined blocks did. The Designer relies on this (store → api → model → registry → theme → renderer → inspector → panels → app) and needed no code changes. Do not add async to order-dependent scripts.

How cache-busting works — the ?v= token

The token is a short hash of the file's modification time and size:

$st = stat($file);
$version = substr(md5($st['mtime'] . ':' . $st['size']), 0, 10);

That is a stat() — the file is never read just to compute a version, so building the page stays cheap. The lifecycle:

  1. You edit reports.js. Its mtime changes.
  2. The next page render emits reports.js?v=newtoken.
  3. The browser has never seen that URL, so it downloads it. Your users get the update on a normal reload.
  4. Every later visit re-uses the cached copy — zero bytes over the wire.

Two conditional-request shortcuts are also honoured, for proxies or clients that ask anyway. Both answer 304 Not Modified with an empty body:

Request : If-None-Match: "4f0dea4479"          →  304, 0 bytes
Request : If-Modified-Since: <a later date>    →  304, 0 bytes

What the asset route refuses to serve

A public route that reads files from disk must be strict. This one is allow-list based, not block-list based:

Verified behaviour — every one of these returns 404:

/module-asset/Designer/Views/index.php                    404   (.php not allowed)
/module-asset/Designer/Controllers/DesignerController.php 404   (.php not allowed)
/module-asset/Designer/module.json                        404   (extension not allowed)
/module-asset/Designer/../../config/database.php          404   (traversal)
/module-asset/Designer/Views/app/../../../../core/DB.php  404   (traversal)
/module-asset/Bad-Module!/x.js                            404   (illegal module name)

Measured results

The Designer, before and after (same page, same features):

MeasurementBeforeAfter
HTML document929 KB50 KB (−94.5%)
First visit, total929 KB869 KB
Every later visit929 KB50 KB
Saved per repeat visit819 KB

The first visit barely changes — the same bytes still have to arrive once. The win is every visit after that, which is what a tool you open all day actually does.

The 78 KB duplicate-CSS story

While auditing, designer.css turned out to contain an exact copy of itself: lines 168–1033 repeated verbatim at 1034–1899. A copy–paste accident, and a quiet hazard — edit one copy and not the other and the two silently disagree.

Deleting one copy was safe to prove rather than assume, because the copies were identical and adjacent, so the cascade could not change. The proof is a technique worth re-using for any risky CSS change — a computed-style fingerprint:

// Run in the browser console BEFORE the change, then again AFTER.
// Identical hash = the change is visually a no-op, across the whole page.
(() => {
  const PROPS = ['display','position','width','height','margin','padding','border',
                 'background-color','color','font-size','font-weight',
                 'grid-template-columns','gap','border-radius','box-shadow','flex','opacity','z-index'];
  const fp = [...document.querySelectorAll('*')].map(el => {
    const cs = getComputedStyle(el);
    return el.tagName + '|' + PROPS.map(p => cs.getPropertyValue(p)).join('~');
  });
  let h = 0; const s = fp.join('\n');
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
  return { elements: fp.length, hash: h };
})()

Result: the same hash over 794 elements, with 593 fewer CSS rules parsed. The duplicate was also being copied into every generated app, so each built app's components.css shrank from 84 KB to 64 KB as a bonus.

Incremental saves — sending only what changed

The whole design is one JSON document. Autosave used to POST all of it one second after every edit — fine for a two-table project, wasteful for a real one:

Project sizeEvery autosave used to send
1 table~53 KB
20 tables~470 KB
…while a single page slot is about 3 KB.

Now the client compares the current state against the last copy the server acknowledged and sends only the slots that differ:

POST /project/{slug}/designer/save
{
  "ops": [
    { "path": ["pages", "customers", "add"], "value": { …the page slot… } }
  ],
  "baseToken": "9f2c1ad39b4e77a1"
}

Why a diff and not "dirty" flags

The obvious design is to mark things dirty as the user edits them. It is also the design that loses work: one mutation path that forgets to set its flag, and that edit is never sent — silently, and usually only in production. So the changed slots are worked out by comparing the data. A diff cannot forget; its worst failure is being too generous and sending a slot that did not really change.

The four safety rails

  1. Only page containers are diffedpages, templates, common. If any other top-level key changed, the client sends the whole document instead.
  2. The base token. Every save returns a fingerprint of the stored design. The next delta sends it back; if it does not match, the stored design moved on (a second tab, another user) and the server answers { needFull: true } rather than merging onto a state nobody saw. The client then resends everything, automatically.
  3. The server validates every path. Only those three roots, only at slot depth, segments restricted to [A-Za-z0-9_.-], values must be objects. Anything else is refused and the client falls back to a full save.
  4. Any failure degrades to the old behaviour. A network error clears the baseline so the next save is a full one. The first save of a session is always full — that is what establishes the token.

Measured

A realistic session over a 20-table project — five edits touching two pages and one template — verified by replaying the ops through the real server merge and comparing the result against a full save of the same final state:

server state after 5 delta saves === full-save state :  IDENTICAL
bytes if every save were full  : 71.3 KB
bytes actually sent (delta)    :  0.8 KB
reduction                      : 98.9%
Worth knowing: JSON round-trips through PHP turn an empty object {} into an empty array [], because PHP has one array type for both. That has always happened on full saves too, and nothing reads those values differently — but it is why the correct test compares "after delta saves" against "after a full save", not against the raw browser state.

How to measure before you optimise

The audit's most useful outcome was the list of things that turned out not to need fixing. The canvas re-renders by replacing all of its HTML on every change, which sounds expensive. Measured, it is not:

// Average a real operation over many runs — one run is mostly noise.
const time = (label, fn, n = 20) => {
  const t0 = performance.now();
  for (let i = 0; i < n; i++) fn();
  console.log(label, ((performance.now() - t0) / n).toFixed(2) + ' ms');
};
time('render (typical page)', () => PPD.renderer.render());
OperationTime
Render, typical page0.3 ms
Render, 48-field Cell Grid (794 DOM nodes)1.0 ms
Render, 120 fields1.6 ms
Selection change (canvas + properties panel)~2.5 ms

Rewriting that render path would have been days of risk for an invisible gain. The real costs were elsewhere entirely — bytes on the wire and a duplicated file.

The habit: measure first, fix what the numbers point at, then measure again to prove the fix. "This looks slow" is a hypothesis, not a finding.

Checklist & troubleshooting


Related: Building Designer Components and Building Field Plugins in this Help section. Implementation lives in core/ModuleAssets.php, wired in public/index.php.