← All documentation
Topics

- Working with data
- Designer components
- Field controls
- Search & filtering
- Calendars
- PDF & printing
- Email
- SMS
- Notifications
- Security
- Helper functions
- JavaScript API
- Performance

# 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
- The solution — `ModuleAssets`
- Using it in your own module (3 lines)
- How cache-busting works — the `?v=` token
- What the asset route refuses to serve
- Measured results
- The 78 KB duplicate-CSS story
- Incremental saves — sending only what changed
- How to measure before you optimise
- Checklist & troubleshooting

## 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:

- It answers early. The handler runs in `public/index.php` before the container, module discovery and the database are touched. An asset request costs a `stat()` and a `readfile()`, not a full application boot.
- It is cached for a year. Responses carry `Cache-Control: public, max-age=31536000, immutable`. The browser will not even ask again — it reads its own copy.
- It still updates instantly. The `?v=` token is derived from the file, so editing the file changes the URL, and a changed URL is a cache miss. No "clear your cache" ritual, ever.

## 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:

- Extension allow-list. Only `js, mjs, css, map, svg, png, jpg, jpeg, gif, webp, woff, woff2`. A `.php` file is never served, so your controllers and `module.json` stay private.
- Module names are restricted to `[A-Za-z0-9_]+`.
- Traversal is blocked twice — literal `..` is rejected, and the resolved path (after `realpath()`) must still sit inside the module folder. That defeats symlink tricks too.
- `X-Content-Type-Options: nosniff` is always sent.

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

Measurement | Before | After
--- | --- | ---
HTML document | 929 KB | 50 KB (−94.5%)
First visit, total | 929 KB | 869 KB
Every later visit | 929 KB | 50 KB
Saved per repeat visit | — | 819 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 size | Every 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 diffed — `pages`, `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());

Operation | Time
--- | ---
Render, typical page | 0.3 ms
Render, 48-field Cell Grid (794 DOM nodes) | 1.0 ms
Render, 120 fields | 1.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

- My edit isn't showing up. Confirm the URL's `?v=` changed. If it did not, the file's mtime did not change (some editors write in place) — `touch` the file.
- 404 on my asset. Check the extension is on the allow-list, the path is relative to `modules/<Module>/` (so it starts with `Views/…`), and the module folder name matches exactly — it is case-sensitive.
- Scripts run in the wrong order. Emit them in dependency order and do not add `async`.
- Keep inlining when it is genuinely per-request. Bootstrap data such as `window.PpDesignerBootstrap` changes with every page and every project, so it belongs in the HTML. Cache what is static; inline what is not.
- Every module is migrated. All 98 inlined includes across 18 module views now use `ModuleAssets`. The biggest single win was the Style module, which was inlining the Designer's `registry.js` and `designer.css` — 467 KB — into every page load. New views should follow the same pattern; only genuinely per-request data stays inline.

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