Переглянути джерело

Add the reenter directive (server DTO + client dispatch)

reenter tells the client to re-fetch the page's entrypoint for the
current URL (with the current slot headers) and apply its directives in
place - unlike navigate there is no page reload, so client-side state
such as form dirty tracking survives.

Server: ReenterDirectiveDto in DirectiveDto.vala, payload-less (the wire
form is exactly {"type":"reenter"}), wired into the from_json switch.
No DirectiveBuilder helper is added: like any DTO it is constructed
directly, e.g. new Model.ReenterDirectiveDto().

Client (js/statum-core.js): the directive switch collects
result.reenter; executeTerminal is now async and runs reenter after the
form directives and before navigate/post (navigation wins when both are
present, running after the refresh). The loadEntrypoint refresh is
wrapped in try/catch: a failure is logged via console.error and
swallowed, never rejecting the action promise or blocking the remaining
terminal directives. handleResponse awaits executeTerminal, so action
promises (stm-clear-on-success release, statum.act) resolve only after
the re-entered entrypoint state has been applied.

Tests: Vala round-trip in TestMain.vala (serialise, materialise, array
helpers); dom scenarios 17-19 (in-place refresh fetched exactly twice,
reenter-before-navigate ordering via a shared event log, and failure
resilience: console.error surfaced, navigate still runs, promise
resolves). run.sh now runs all nineteen scenarios.

Docs: model.md directive list + full Reenter Directive section and the
transmit retry note; getting-started.md one-liner under the
DirectiveBuilder table.
saddle-ClankerSaddle 3 тижнів тому
батько
коміт
cd1d7dac9e
7 змінених файлів з 246 додано та 13 видалено
  1. 6 1
      getting-started.md
  2. 28 8
      js/statum-core.js
  3. 10 1
      model.md
  4. 29 0
      src/Model/DirectiveDto.vala
  5. 27 0
      tests/TestMain.vala
  6. 2 2
      tests/dom/run.sh
  7. 144 1
      tests/dom/scenarios.js

+ 6 - 1
getting-started.md

@@ -531,7 +531,12 @@ Started via `directives()` on any handler. Fluent, self-returning:
 | `.subscribe_global(type)` | Subscribes to a global broadcast slot's live updates. |
 | `.unsubscribe(key)` | Unsubscribe. |
 | `.set_frame(FrameDto)` | `set` from an already-signed frame. |
-| `.invalidate(signature)` | `invalidate` (drop a bad frame). |
+| `.invalidate(signature)` | `invalidate` (drop a bad frame).
+
+The `reenter` directive (re-fetch the page's entrypoint in place, without a
+page reload) has no builder method: return it via the DTO itself,
+`new Model.ReenterDirectiveDto()`, like any directive object. See
+[model.md](model.md) for its payload-free wire form and client ordering. |
 
 ### `StatumRequest`
 

+ 28 - 8
js/statum-core.js

@@ -757,18 +757,18 @@
    * Apply the non-terminal directives from a response (set/clear/subscribe/
    * unsubscribe/notify/transmit-collection), then `render`.
    *
-   * Terminal directives (`navigate`, `post`), error directives, and any
-   * transmit/retry intent are collected on the returned result rather than
+   * Terminal directives (`navigate`, `post`, `reenter`), error directives, and
+   * any transmit/retry intent are collected on the returned result rather than
    * executed here: {@link handleResponse} performs the asynchronous
    * transmission (and optional retry) and then runs terminal navigation, so
    * that a `transmit` with `retry` can re-issue the request before the page is
    * navigated away.
    * @param {object[]} directives
    * @param {{originElement?: HTMLElement}} [ctx]
-   * @returns {{errors: StatumError[], transmitKeys: string[], retry: boolean, navigate: object|null, post: object|null}}
+   * @returns {{errors: StatumError[], transmitKeys: string[], retry: boolean, navigate: object|null, post: object|null, reenter: boolean}}
    */
   function applyDirectives(directives, ctx) {
-    var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null, formSubmits: [], formResets: [] };
+    var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null, reenter: false, formSubmits: [], formResets: [] };
     var list = Array.isArray(directives) ? directives : [];
 
     for (var i = 0; i < list.length; i++) {
@@ -801,6 +801,7 @@
           }
           break;
         case 'navigate': result.navigate = d; break;
+        case 'reenter': result.reenter = true; break;
         case 'post': result.post = d; break;
         case 'form-submit': result.formSubmits.push(d); break;
         case 'form-reset': result.formResets.push(d); break;
@@ -811,10 +812,25 @@
     return result;
   }
 
-  /** Execute the terminal directives collected by {@link applyDirectives}. */
-  function executeTerminal(result) {
+  /**
+   * Execute the terminal directives collected by {@link applyDirectives}.
+   *
+   * Order: `form-reset`/`form-submit` first (their work is synchronous), then
+   * a `reenter` (the entrypoint refresh is awaited so the action's promise
+   * resolves only once the re-entered state has been applied), then
+   * `navigate`/`post` — navigation wins over reenter's refresh when both are
+   * present, running after it. Async because of reenter.
+   */
+  async function executeTerminal(result) {
     result.formResets.forEach(doFormReset);
     result.formSubmits.forEach(doFormSubmit);
+    if (result.reenter) {
+      try {
+        await loadEntrypoint();
+      } catch (err) {
+        console.error('[statum] reenter: entrypoint refresh failed', err);
+      }
+    }
     if (result.navigate) doNavigate(result.navigate);
     if (result.post) doPost(result.post);
   }
@@ -868,7 +884,11 @@
    * posted (applying the directives the slot endpoint returns); then, if a
    * transmit requested a retry, the originating request (`ctx.retry`) is
    * re-issued and this response's terminal directives and errors are skipped.
-   * Otherwise terminal navigation runs, then error directives are surfaced.
+   * Otherwise terminal directives run — form resets/submits, then an awaited
+   * `reenter` entrypoint refresh (a failure is logged, never propagated), then
+   * `navigate`/`post` — and finally error directives are surfaced. Because
+   * the terminal phase is awaited, an action promise resolves only after a
+   * `reenter` refresh has been applied.
    * @param {Response} res
    * @param {{originElement?: HTMLElement, retry?: Function}} [ctx]
    */
@@ -895,7 +915,7 @@
       return;
     }
 
-    executeTerminal(result);
+    await executeTerminal(result);
 
     var errors = result.errors || [];
     for (var i = 0; i < errors.length; i++) {

+ 10 - 1
model.md

@@ -39,6 +39,7 @@ A statum snapshot captures an aspect of the application state at a particular po
 
 - `type`: One of
     - `"navigate"`: Used to redirect the frontend to a new URI.
+    - `"reenter"`: Used to re-fetch the page's entrypoint for the current URL and apply its directives in place — no page reload. Carries no other properties.
     - `"post"`: Used to perform a full-page form POST navigation to a URI. A `"post"` directive is always executed after all other directives in the same response.
     - `"set"`: Used to set a slot to a new snapshot.
     - `"clear"`: Used to clear a slot.
@@ -57,6 +58,14 @@ Depending on the type, it will have other properties.
 
 - `uri`: The URI to navigate to.
 
+### Reenter Directive
+
+Carries no properties beyond `type`.
+
+Tells the client to re-fetch the page's entrypoint for the current URL — the same request the initial load makes, with the client's current slot headers — and apply its directives in place. Unlike `navigate` the page is not reloaded: the document, its listeners and client-side state (such as form dirty tracking) stay alive, so the page's entrypoint state is refreshed without disturbing the user's context.
+
+Execution is terminal but ordered: `reenter` runs after the response's form directives (`form-reset`, `form-submit`) and before `navigate`/`post`, so when both a `reenter` and a `navigate` appear in one response the entrypoint is refreshed first and the navigation then wins. The refresh is awaited before the remaining terminal directives run, which means the originating action's promise (and therefore `stm-clear-on-success` busy-state release and `statum.act` resolution) resolves only after the re-entered entrypoint state has been applied. A failed refresh (for example a network error) is logged to the console and swallowed: it neither rejects the action promise nor prevents a `navigate`/`post` in the same response from running.
+
 ### Post Directive
 
 - `uri`: The URI to perform a form post to.
@@ -110,7 +119,7 @@ Forces the client to transmit the named slot's current frame to the slot post en
 
 When `retry` is `true`, the originating request (the one that returned this directive) is re-issued once the transmission and its response directives have been applied, so the server can re-evaluate the request against the freshly transmitted state. The retry carries rebuilt slot headers reflecting the transmitted (and possibly refreshed) snapshots. A retried response is processed like any other, so combining `transmit` with `retry` is how the server forces a state sync followed by a fresh evaluation of the same request.
 
-Transmit is not terminal: it runs in the same pass as `set` and `clear`. The actual transmission (and any retry) occurs after the response's other non-terminal directives have been applied and the page re-rendered. When a retry is performed, the original response's terminal directives (`navigate`, `post`) and `error` directives are not applied — the retried response supersedes them.
+Transmit is not terminal: it runs in the same pass as `set` and `clear`. The actual transmission (and any retry) occurs after the response's other non-terminal directives have been applied and the page re-rendered. When a retry is performed, the original response's terminal directives (`navigate`, `reenter`, `post`) and `error` directives are not applied — the retried response supersedes them.
 
 ### Invalidate Directive
 

+ 29 - 0
src/Model/DirectiveDto.vala

@@ -61,6 +61,7 @@ namespace Statum.Model {
             }
             switch ((!)type) {
                 case "navigate": return NavigateDirectiveDto.get_mapper().materialise(json);
+                case "reenter": return ReenterDirectiveDto.get_mapper().materialise(json);
                 case "post": return PostDirectiveDto.get_mapper().materialise(json);
                 case "set": return SetDirectiveDto.get_mapper().materialise(json);
                 case "clear": return ClearDirectiveDto.get_mapper().materialise(json);
@@ -132,6 +133,34 @@ namespace Statum.Model {
 
     }
 
+    /**
+     * Tells the frontend to re-fetch the page's entrypoint for the current URL
+     * (with the current slot headers) and apply its directives in place — no
+     * page reload. Unlike {@link NavigateDirectiveDto} the document stays
+     * alive, so client-side state (form dirty tracking, open menus) survives.
+     *
+     * Carries no payload. Executed after the form directives and before any
+     * `navigate`/`post` in the same response, so a page that reenters and then
+     * navigates away still refreshes its entrypoint first.
+     */
+    public class ReenterDirectiveDto : DirectiveDto {
+
+        public override string discriminator { get { return "reenter"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return ReenterDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public ReenterDirectiveDto() { }
+
+        public static PropertyMapper<ReenterDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<ReenterDirectiveDto>(cfg => {
+                cfg.set_constructor(() => new ReenterDirectiveDto());
+            });
+        }
+
+    }
+
     /**
      * Performs a full-page form POST navigation to a URI. A post directive is
      * always executed after all other directives in the same response.

+ 27 - 0
tests/TestMain.vala

@@ -167,6 +167,27 @@ namespace Statum.Tests {
         }
     }
 
+    void test_directive_roundtrip() throws GLib.Error {
+        // Payload-less directive: the wire form must be exactly `{"type":...}`.
+        var reenter = new Model.ReenterDirectiveDto();
+        var wire = reenter.to_json().stringify();
+        check(wire.contains("\"type\":\"reenter\""), "directives: reenter serialises with its discriminator");
+        check(!wire.contains("uri"), "directives: reenter carries no payload fields");
+
+        var back = Model.DirectiveDto.from_json(new JsonElement.from_string(wire).as<JsonObject>());
+        check(back is Model.ReenterDirectiveDto, "directives: reenter round-trips to ReenterDirectiveDto");
+
+        // The typed path stays symmetric through the array helpers.
+        var lot = new Series<Model.DirectiveDto>();
+        lot.add(reenter);
+        lot.add(new Model.NavigateDirectiveDto.with_uri("/after"));
+        var parsed = Model.DirectiveDto.deserialize_array(Model.DirectiveDto.serialize_array(lot)).to_array();
+        check(parsed.length == 2 && parsed[0] is Model.ReenterDirectiveDto
+            && parsed[1] is Model.NavigateDirectiveDto
+            && ((Model.NavigateDirectiveDto) parsed[1]).uri == "/after",
+            "directives: reenter survives the directive array helpers");
+    }
+
     void main() {
         GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
 
@@ -208,6 +229,12 @@ namespace Statum.Tests {
             failures++;
             print("FAIL typed action private threw: %s\n", e.message);
         }
+        try {
+            test_directive_roundtrip();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL directive round-trip threw: %s\n", e.message);
+        }
 
         print("---- %d passed, %d failed ----\n", passes, failures);
         if (failures > 0) {

+ 2 - 2
tests/dom/run.sh

@@ -5,7 +5,7 @@
 # bootstrap as tests/markdown/run.sh), then runs each scenario in a fresh
 # context. Exits non-zero when any scenario fails.
 #
-# Usage: tests/dom/run.sh [scenario]   (default: all sixteen)
+# Usage: tests/dom/run.sh [scenario]   (default: all nineteen)
 set -e
 cd "$(dirname "$0")/../.."
 
@@ -19,7 +19,7 @@ else
   QJS=$(command -v qjs)
 fi
 
-SCENARIOS="${1:-1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16}"
+SCENARIOS="${1:-1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19}"
 
 for n in $SCENARIOS; do
   echo "== dom scenario $n =="

+ 144 - 1
tests/dom/scenarios.js

@@ -27,8 +27,11 @@
  * 14  two sibling forms: clearing A leaves B alone
  * 15  statum.forms.reset: resets exactly the given form
  * 16  handler order: stm-prefill registered before stm-class.*
+ * 17  reenter: action triggers an in-place entrypoint refresh (fetched twice)
+ * 18  reenter ordering: refresh before navigate, both execute
+ * 19  reenter failure: logged only; navigate still runs; promise resolves
  *
- * Usage: qjs tests/dom/scenarios.js <1..16>
+ * Usage: qjs tests/dom/scenarios.js <1..19>
  */
 import * as std from 'std';
 import { installShim } from './shim.js';
@@ -506,6 +509,146 @@ async function main() {
     check(log.warns.length === 0, 'warnings: ' + JSON.stringify(log.warns));
     check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
 
+  } else if (scenario === '17') {
+    // reenter: an action response re-fetches the entrypoint in place.
+    var t = el('span'); t.setAttribute('stm-text', 'm.mood');
+    body.appendChild(t);
+
+    sigCounter++;
+    var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
+    var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } };
+    var entrypointCount = 0;
+    globalThis.__shim.setFetch(function (url) {
+      if (String(url).indexOf('/_statum/entrypoint') !== -1) {
+        entrypointCount++;
+        return globalThis.__shim.okResponse([{
+          type: 'set',
+          snapshot_frame: {
+            content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2),
+            signature: 'sig-r17-' + entrypointCount
+          }
+        }]);
+      }
+      // The action request: reply with a reenter directive.
+      return globalThis.__shim.okResponse([{ type: 'reenter' }]);
+    });
+
+    await s._bridge.loadEntrypoint();
+    await spin();
+    check(t.textContent === 'first', 'initial entrypoint state not applied: ' + JSON.stringify(t.textContent));
+    var initialFetches = globalThis.__shim.fetchCalls.length;
+
+    await s._bridge.performAction({ uri: '/save', method: 'POST' }, {});
+    await spin(50);
+
+    var entryFetches = globalThis.__shim.fetchCalls.filter(function (c) {
+      return c.url.indexOf('/_statum/entrypoint') !== -1;
+    });
+    check(entryFetches.length === 2, 'entrypoint fetched ' + entryFetches.length + ' time(s), expected 2 (initial + reenter)');
+    check(globalThis.__shim.fetchCalls.length === initialFetches + 2,
+      'exactly two new requests were expected (action + refresh), got ' +
+      (globalThis.__shim.fetchCalls.length - initialFetches));
+    check(t.textContent === 'second', 'reentered entrypoint state not applied: ' + JSON.stringify(t.textContent));
+    check(globalThis.location.href === 'http://localhost/', 'a bare reenter must not navigate');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '18') {
+    // reenter + navigate in one response: refresh first, then navigate wins.
+    var t = el('span'); t.setAttribute('stm-text', 'm.mood');
+    body.appendChild(t);
+
+    sigCounter++;
+    var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
+    var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } };
+    var entrypointCount = 0;
+    var order = []; // shared log: reenter's fetch vs the location change
+    globalThis.__shim.setFetch(function (url) {
+      if (String(url).indexOf('/_statum/entrypoint') !== -1) {
+        entrypointCount++;
+        order.push('entrypoint:' + entrypointCount);
+        return globalThis.__shim.okResponse([{
+          type: 'set',
+          snapshot_frame: {
+            content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2),
+            signature: 'sig-r18-' + entrypointCount
+          }
+        }]);
+      }
+      return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]);
+    });
+    // Record the navigation in the shared order log.
+    var originalHref = globalThis.location.href;
+    Object.defineProperty(globalThis.location, 'href', {
+      configurable: true,
+      get: function () { return originalHref; },
+      set: function (v) { order.push('navigate:' + v); }
+    });
+
+    await s._bridge.loadEntrypoint();
+    await spin();
+    check(t.textContent === 'first', 'initial entrypoint state not applied');
+
+    await s._bridge.performAction({ uri: '/save', method: 'POST' }, {});
+    await spin(50);
+
+    check(globalThis.__shim.fetchCalls.filter(function (c) {
+      return c.url.indexOf('/_statum/entrypoint') !== -1;
+    }).length === 2, 'reenter did not refresh the entrypoint');
+    check(order.indexOf('navigate:/after') !== -1, 'navigate did not run');
+    check(order.indexOf('entrypoint:2') < order.indexOf('navigate:/after'),
+      'reenter must run before navigate, order was ' + JSON.stringify(order));
+    check(t.textContent === 'second', 'refresh state not applied before navigating');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '19') {
+    // A failing reenter refresh: logged, swallowed, navigate still runs, and
+    // the action promise resolves.
+    var t = el('span'); t.setAttribute('stm-text', 'm.mood');
+    body.appendChild(t);
+
+    sigCounter++;
+    var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
+    var entrypointCount = 0;
+    globalThis.__shim.setFetch(function (url) {
+      if (String(url).indexOf('/_statum/entrypoint') !== -1) {
+        entrypointCount++;
+        if (entrypointCount === 1) {
+          return globalThis.__shim.okResponse([{
+            type: 'set',
+            snapshot_frame: { content: JSON.stringify(snap1), signature: 'sig-r19-1' }
+          }]);
+        }
+        return Promise.reject(new Error('network down'));
+      }
+      return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]);
+    });
+    var navigatedTo = null;
+    Object.defineProperty(globalThis.location, 'href', {
+      configurable: true,
+      get: function () { return 'http://localhost/'; },
+      set: function (v) { navigatedTo = v; }
+    });
+
+    await s._bridge.loadEntrypoint();
+    await spin();
+
+    var resolved = false;
+    var rejected = null;
+    await s._bridge.performAction({ uri: '/save', method: 'POST' }, {}).then(function () {
+      resolved = true;
+    }, function (e) {
+      rejected = e;
+    });
+    await spin(50);
+
+    check(resolved && rejected === null, 'action promise should resolve after a failed reenter, rejected=' + String(rejected));
+    check(log.errors.some(function (m) { return m.indexOf('[statum] reenter: entrypoint refresh failed') !== -1; }),
+      'reenter failure was not logged: ' + JSON.stringify(log.errors));
+    check(navigatedTo === '/after', 'navigate did not run after the failed reenter, navigatedTo=' + JSON.stringify(navigatedTo));
+    check(log.errors.length === 1, 'exactly one console.error expected: ' + JSON.stringify(log.errors));
+    // Core logs the swallowed reenter failure by design; nothing else may error.
+    log.errors.length = 0;
+
   } else {
     fail('unknown scenario: ' + scenario);
   }