<?php
/*
 * Owner/admin-only guide: Performance & Asset Caching.
 * Served by modules/Help/HelpController (topic = "performance").
 * $c() prints an HTML-safe code block from a nowdoc so samples containing
 * <, > and & display literally.
 */
$c = static fn (string $s): string => '<pre><code>' . htmlspecialchars($s) . '</code></pre>';
?>

<h1><i class="bi bi-speedometer2"></i> Performance &amp; Asset Caching</h1>
<p class="lead">How <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> delivers its module code to the browser, why the Designer used to
re&ndash;download ~840&nbsp;KB on every visit, and how to use the same caching for
<em>your</em> modules. Also: the measuring habits that stop you from optimising the
wrong thing.</p>

<div class="alert alert-info">
    <strong>Who is this for?</strong> Owners and administrators. Hidden from regular users.
</div>

<h2>Contents</h2>
<ul>
    <li><a href="#problem">The problem &mdash; inlined assets can never be cached</a></li>
    <li><a href="#solution">The solution &mdash; <code>ModuleAssets</code></a></li>
    <li><a href="#use">Using it in your own module (3 lines)</a></li>
    <li><a href="#versions">How cache-busting works &mdash; the <code>?v=</code> token</a></li>
    <li><a href="#security">What the asset route refuses to serve</a></li>
    <li><a href="#results">Measured results</a></li>
    <li><a href="#duplicate">The 78&nbsp;KB duplicate-CSS story</a></li>
    <li><a href="#delta">Incremental saves &mdash; sending only what changed</a></li>
    <li><a href="#measure">How to measure before you optimise</a></li>
    <li><a href="#checklist">Checklist &amp; troubleshooting</a></li>
</ul>

<hr>

<h2 id="problem">The problem &mdash; inlined assets can never be cached</h2>
<p>Only the <code>public/</code> folder is reachable from the web. Everything under
<code>modules/</code> is deliberately not, which protects your controllers and config.
That left module views one way to deliver their JavaScript and CSS &mdash; print the file
<em>into</em> the HTML:</p>

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

<p>It works, and for a small file it is perfectly fine. The catch is what it costs on a
<strong>second</strong> 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 &mdash; and
the page is re-sent every single time.</p>

<div class="alert alert-warning">
    <strong>What this looked like in the Designer.</strong> Nine JavaScript files plus one
    stylesheet, about <strong>840&nbsp;KB</strong>, pasted into the document. Opening the
    Designer ten times downloaded that 840&nbsp;KB ten times &mdash; even though not one
    byte had changed.
</div>

<h2 id="solution">The solution &mdash; <code>ModuleAssets</code></h2>
<p><code>core/ModuleAssets.php</code> gives module files a real, public, versioned URL
without moving them out of the module:</p>

<?= $c(<<<'TXT'
/module-asset/Designer/Views/app/registry.js?v=4f0dea4479
 └── route      └── module   └── path inside the module   └── content version
TXT) ?>

<p>Three things make this fast:</p>
<ul>
    <li><strong>It answers early.</strong> The handler runs in <code>public/index.php</code>
        <em>before</em> the container, module discovery and the database are touched. An
        asset request costs a <code>stat()</code> and a <code>readfile()</code>, not a full
        application boot.</li>
    <li><strong>It is cached for a year.</strong> Responses carry
        <code>Cache-Control: public, max-age=31536000, immutable</code>. The browser will
        not even ask again &mdash; it reads its own copy.</li>
    <li><strong>It still updates instantly.</strong> The <code>?v=</code> 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.</li>
</ul>

<h2 id="use">Using it in your own module (3 lines)</h2>
<p>Say your module is <code>modules/Reports/</code> with
<code>Views/assets/reports.js</code> and <code>Views/assets/reports.css</code>.</p>

<p><strong>1.</strong> Import the helper at the top of your view:</p>
<?= $c(<<<'PHP'
use PpPhp\Core\ModuleAssets;
PHP) ?>

<p><strong>2.</strong> In <code>&lt;head&gt;</code>, replace the inlined style:</p>
<?= $c(<<<'PHP'
<!-- before -->
<style><?php readfile(__DIR__ . '/assets/reports.css'); ?></style>

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

<p><strong>3.</strong> Before <code>&lt;/body&gt;</code>, replace the inlined script:</p>
<?= $c(<<<'PHP'
<!-- before -->
<script><?php readfile(__DIR__ . '/assets/reports.js'); ?></script>

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

<p>That is the whole change. If you need only the URL &mdash; for a preload hint, a web
worker, or an image &mdash; use:</p>

<?= $c(<<<'PHP'
$url = ModuleAssets::url('Reports', 'Views/assets/logo.svg', $baseUrl);
// /module-asset/Reports/Views/assets/logo.svg?v=1a2b3c4d5e
PHP) ?>

<div class="alert alert-info">
    <strong>Load order is preserved.</strong> Plain <code>&lt;script src&gt;</code> tags run
    in the order they appear, exactly as inlined blocks did. The Designer relies on this
    (<code>store → api → model → registry → theme → renderer → inspector → panels → app</code>)
    and needed no code changes. Do <em>not</em> add <code>async</code> to
    order-dependent scripts.
</div>

<h2 id="versions">How cache-busting works &mdash; the <code>?v=</code> token</h2>
<p>The token is a short hash of the file's <strong>modification time and size</strong>:</p>

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

<p>That is a <code>stat()</code> &mdash; the file is never read just to compute a version,
so building the page stays cheap. The lifecycle:</p>

<ol>
    <li>You edit <code>reports.js</code>. Its mtime changes.</li>
    <li>The next page render emits <code>reports.js?v=<em>newtoken</em></code>.</li>
    <li>The browser has never seen that URL, so it downloads it. Your users get the update
        on a normal reload.</li>
    <li>Every later visit re-uses the cached copy &mdash; zero bytes over the wire.</li>
</ol>

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

<?= $c(<<<'TXT'
Request : If-None-Match: "4f0dea4479"          →  304, 0 bytes
Request : If-Modified-Since: <a later date>    →  304, 0 bytes
TXT) ?>

<h2 id="security">What the asset route refuses to serve</h2>
<p>A public route that reads files from disk must be strict. This one is allow-list based,
not block-list based:</p>

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

<p>Verified behaviour &mdash; every one of these returns <code>404</code>:</p>
<?= $c(<<<'TXT'
/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)
TXT) ?>

<h2 id="results">Measured results</h2>
<p>The Designer, before and after (same page, same features):</p>

<table class="table table-sm">
    <thead><tr><th>Measurement</th><th>Before</th><th>After</th></tr></thead>
    <tbody>
        <tr><td>HTML document</td><td>929&nbsp;KB</td><td><strong>50&nbsp;KB</strong> (&minus;94.5%)</td></tr>
        <tr><td>First visit, total</td><td>929&nbsp;KB</td><td>869&nbsp;KB</td></tr>
        <tr><td><strong>Every later visit</strong></td><td>929&nbsp;KB</td><td><strong>50&nbsp;KB</strong></td></tr>
        <tr><td>Saved per repeat visit</td><td>&mdash;</td><td><strong>819&nbsp;KB</strong></td></tr>
    </tbody>
</table>

<p>The first visit barely changes &mdash; 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.</p>

<h2 id="duplicate">The 78&nbsp;KB duplicate-CSS story</h2>
<p>While auditing, <code>designer.css</code> turned out to contain an exact copy of itself:
lines 168&ndash;1033 repeated verbatim at 1034&ndash;1899. A copy&ndash;paste accident, and
a quiet hazard &mdash; edit one copy and not the other and the two silently disagree.</p>

<p>Deleting one copy was safe to <em>prove</em> 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 &mdash; a <strong>computed-style fingerprint</strong>:</p>

<?= $c(<<<'JS'
// 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 };
})()
JS) ?>

<p>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
<code>components.css</code> shrank from 84&nbsp;KB to 64&nbsp;KB as a bonus.</p>

<h2 id="delta">Incremental saves &mdash; sending only what changed</h2>
<p>The whole design is one JSON document. Autosave used to POST all of it one second after
every edit &mdash; fine for a two-table project, wasteful for a real one:</p>

<table class="table table-sm">
    <thead><tr><th>Project size</th><th>Every autosave used to send</th></tr></thead>
    <tbody>
        <tr><td>1 table</td><td>~53&nbsp;KB</td></tr>
        <tr><td>20 tables</td><td>~470&nbsp;KB</td></tr>
        <tr><td colspan="2" class="text-muted">&hellip;while a single page slot is about <strong>3&nbsp;KB</strong>.</td></tr>
    </tbody>
</table>

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

<?= $c(<<<'JSON'
POST /project/{slug}/designer/save
{
  "ops": [
    { "path": ["pages", "customers", "add"], "value": { …the page slot… } }
  ],
  "baseToken": "9f2c1ad39b4e77a1"
}
JSON) ?>

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

<h3>The four safety rails</h3>
<ol>
    <li><strong>Only page containers are diffed</strong> &mdash; <code>pages</code>,
        <code>templates</code>, <code>common</code>. If any other top-level key changed, the
        client sends the whole document instead.</li>
    <li><strong>The base token.</strong> 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
        <code>{ needFull: true }</code> rather than merging onto a state nobody saw. The
        client then resends everything, automatically.</li>
    <li><strong>The server validates every path.</strong> Only those three roots, only at
        slot depth, segments restricted to <code>[A-Za-z0-9_.-]</code>, values must be
        objects. Anything else is refused and the client falls back to a full save.</li>
    <li><strong>Any failure degrades to the old behaviour.</strong> A network error clears
        the baseline so the next save is a full one. The first save of a session is always
        full &mdash; that is what establishes the token.</li>
</ol>

<h3>Measured</h3>
<p>A realistic session over a 20-table project &mdash; five edits touching two pages and one
template &mdash; verified by replaying the ops through the real server merge and comparing
the result against a full save of the same final state:</p>

<?= $c(<<<'TXT'
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%
TXT) ?>

<div class="alert alert-info">
    <strong>Worth knowing:</strong> JSON round-trips through PHP turn an empty object
    <code>{}</code> into an empty array <code>[]</code>, because PHP has one array type for
    both. That has always happened on full saves too, and nothing reads those values
    differently &mdash; but it is why the correct test compares "after delta saves" against
    "after a full save", not against the raw browser state.
</div>

<h2 id="measure">How to measure before you optimise</h2>
<p>The audit's most useful outcome was the list of things that turned out <em>not</em> to
need fixing. The canvas re-renders by replacing all of its HTML on every change, which
sounds expensive. Measured, it is not:</p>

<?= $c(<<<'JS'
// 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());
JS) ?>

<table class="table table-sm">
    <thead><tr><th>Operation</th><th>Time</th></tr></thead>
    <tbody>
        <tr><td>Render, typical page</td><td>0.3&nbsp;ms</td></tr>
        <tr><td>Render, 48-field Cell Grid (794 DOM nodes)</td><td>1.0&nbsp;ms</td></tr>
        <tr><td>Render, 120 fields</td><td>1.6&nbsp;ms</td></tr>
        <tr><td>Selection change (canvas + properties panel)</td><td>~2.5&nbsp;ms</td></tr>
    </tbody>
</table>

<p>Rewriting that render path would have been days of risk for an invisible gain. The real
costs were elsewhere entirely &mdash; bytes on the wire and a duplicated file.</p>

<div class="alert alert-success">
    <strong>The habit:</strong> measure first, fix what the numbers point at, then measure
    again to prove the fix. "This looks slow" is a hypothesis, not a finding.
</div>

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

<hr>
<p class="text-muted"><small>Related: <em>Building Designer Components</em> and
<em>Building Field Plugins</em> in this Help section. Implementation lives in
<code>core/ModuleAssets.php</code>, wired in <code>public/index.php</code>.</small></p>
