Skip to content

Restore the widget layer, and make session persistence hold - #484

Merged
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix/widgets-layer-and-session-persistence
Aug 4, 2026
Merged

Restore the widget layer, and make session persistence hold#484
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix/widgets-layer-and-session-persistence

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Two unrelated regressions, both silent — nothing threw, nothing warned.

The widget column stopped rendering

The rebrand (#475) renamed the shell element desktop-mode-widgets to os-widgets in includes/render/shell.php and left three TypeScript lookups on the old id.

The one that mattered is src/desktop.ts — it decides whether WidgetLayer is ever constructed:

const widgetsEl = document.getElementById( 'desktop-mode-widgets' ); // → null
if ( widgetsEl ) { widgetLayer = new WidgetLayer( widgetsEl, pluginUrl ); }

getElementById returned null, the guard took the "not present" branch, and the right-hand column rendered empty on every load. The other two stale lookups set the window-links z-order insertion point and the overview inert list.

I swept the rest of the rebrand. Everything else lines up; the remaining desktop-mode-* literals are the frozen ones (localStorage keys, native-window ids) and are correct per AGENTS.md.

Closed windows came back on refresh

Three separate causes, same symptom.

Saves were silently dropped. doSave() returned early when a request was in flight — and the debounce timer had already fired and cleared itself, so nothing retried. Close a second window while the first close is still on the wire and that close was lost for good. It now marks the session dirty and re-runs once the request settles.

The write-ordering key was too coarse. updated was Math.floor( Date.now() / 1000 ). The two writes that race hardest are the keepalive fetch still in flight and the pagehide beacon that supersedes it; at second resolution they tie, and the server's tie rule hands the win to whichever it processes last — which can be the stale one, reinstating a window the user just closed. Now epoch milliseconds on both sides, with openstation_session_now_ms() matching the (int) round( microtime( true ) * 1000 ) idiom already used in presence and content-changes. Sessions written before the switch carry a seconds value, ~1000× smaller, so the first write after an upgrade correctly wins.

The debounce had no floor under it. The existing 500ms window only collapsed changes that arrived closer together than itself — closes a beat apart cleared it every time and posted three times, and a settling in-flight save would hand straight over to its queued successor. SESSION_SAVE_MIN_INTERVAL_MS = 1500 sits underneath the debounce and holds writes to one per interval however the changes are spaced. Nothing is dropped to honour it: a save arriving too soon is delayed, not discarded, and pagehide still flushes past both via sendBeacon.

Tests

createSessionSaver had no coverage at all; it has nine tests now, covering burst collapsing, the in-flight re-send, the rate limit, failure recovery, and the beacon path. shell-element-ids.test.ts pins the five server-rendered shell ids on both sides so a rename can't silently orphan one again.

Each guard was verified to fail against the unfixed code — the two coalescing tests fail without the dirty flag, and the rate-limit test fails with the interval set to 0.

One thing worth calling out for review: the new PHPUnit test_save_session_orders_writes_within_the_same_second documents the ordering contract but is not a regression guard for the millisecond fix — the server comparison was already < and is unit-agnostic. The real guards for that one are the vitest snapshot stamps updated in epoch milliseconds and the PHPUnit fallback-unit test.

Green: build, typecheck, lint, PHPCS, 3667 vitest, 1954 PHPUnit.

Docs updated in architecture.md (the updated ordering contract) and javascript-reference.md (saveSession scheduling semantics).

🤖 Generated with Claude Code

Open WordPress Playground Preview

Two unrelated regressions, both silent.

The widget column stopped rendering. The rebrand renamed the shell
element `desktop-mode-widgets` to `os-widgets` in the markup and left
three TS lookups on the old id. The mount one decided whether
`WidgetLayer` was ever constructed, so `getElementById` returned null,
the guard took the "not present" branch, and the column rendered empty
on every load — no error, no warning. The other two set the
window-links z-order insertion point and the overview inert list.

Closed windows came back on refresh, for three separate reasons:

- A save triggered while another was on the wire was dropped. The
  debounce timer had already fired and cleared itself, so nothing
  retried and the mutation was lost. It now marks the session dirty
  and re-runs once the request settles.

- `updated`, the server's write-ordering key, was stamped in seconds.
  The two writes that race hardest are the `keepalive` fetch still in
  flight and the `pagehide` beacon that supersedes it; at second
  resolution they tie, and the tie rule hands the win to whichever the
  server processes last — which can be the stale one. Now milliseconds
  on both sides. Sessions written before the switch carry a seconds
  value, ~1000x smaller, so the first write after an upgrade wins.

- The 500ms debounce only collapsed changes closer together than its
  own window; closes a beat apart cleared it every time, and a
  settling save would hand straight over to its queued successor. A
  rate limit underneath it holds writes to one per 1500ms. Nothing is
  dropped to honour it — a save arriving too soon is delayed, and
  unload still flushes past everything.

`createSessionSaver` had no coverage; it has nine tests now. The
shell's five server-rendered element ids are pinned on both sides, so
a rename can't silently orphan one again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AllTerrainDeveloper
AllTerrainDeveloper merged commit 9e21440 into trunk Aug 4, 2026
5 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the fix/widgets-layer-and-session-persistence branch August 4, 2026 10:45
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