ReviewOS

stacks/stx

feat(router): no way to re-run the current route's server data — the only refresh available is a full document load

#1858
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

Summary

Once a page is rendered, there is no supported way to re-run its <script server> block. The router's public API has no refresh, and navigating to the URL you are already on early-returns. The force argument that would bypass that early return is a third positional parameter on the router's internal navigate, and the runtime's exported navigate never passes it. So the only way for an app to reflect a server-side mutation is { reload: true } — a deliberate full document load that discards every client signal on the page.

There is also no partial equivalent: no way to re-fetch just one section of the server data.

Impact (real app)

analyticshq's dashboard runs ~25 SQL queries in its server block (resources/views/dashboard.stx:337-503). Adding or deleting one conversion goal re-runs all of them.

resources/views/dashboard.stx:828-842, with the reasoning written out because it looks like a mistake otherwise:

A full document load, deliberately -- this is NOT a swap in disguise. Every goal row carries conversions, converters, rate and total value that come from the LEFT JOIN aggregate in the server block, not from the POST/DELETE response, so there is no client-side shape to re-derive a row from and nothing cheaper to fetch.

It could not be a swap even if there were: the router early-returns on a navigation to the URL it is already on (router client.ts:425), and the force argument that bypasses that is not reachable through the two-parameter navigate() the runtime exposes. { reload: true } skips the router outright by design […]

function refreshFromServer(): void {
  navigate(useRoute().fullPath, { replace: true, reload: true })
}

Called after every goal mutation — dashboard.stx:863 (addGoal), :887 (deleteGoal).

The same all-or-nothing shape drives every filter interaction on the page: 16 plain <a href="?…"> links (dashboard.stx:1191, 1208, 1222, 1227, 1371, 1388, 1445, 1459, 1475, 1489, 1504, 1523, 1642, 1657, 1671, 1685, 1699). Unticking one filter chip costs a full document load. No optimistic update is possible anywhere in the app.

Evidence in stx

packages/router/src/client.ts:1362-1373 — the whole public surface:

var router={
  navigate:navigate,
  navigateTo:navigate,
  prefetch:function(url){ … },
  clearCache:function(){ … },
  cache:cache,
  swap:swap,
  updateNav:updateNav
};

No refresh, no reload, no partial. grep -rn 'refresh' packages/router/src/client.ts → no match.

The same-URL early return, packages/router/src/client.ts:510:

if(pushState!==false&&t.href===location.href&&!t.hash&&!force)return Promise.resolve(false);

force is the third parameter of function navigate(url,pushState,force) (client.ts:481), and the runtime never supplies it — packages/stx/src/signals.ts:1057-1067:

function navigate(url, options) {
  var opts = (options && typeof options === 'object') ? options : { reload: !!options };
  var replace = !!opts.replace;
  var reload  = !!(opts.reload || opts.forceReload);
  if (!reload && window.stxRouter && typeof window.stxRouter.navigate === 'function') {
    window.stxRouter.navigate(url, { replace: replace });   // ← force never passed
    return;
  }
  …
}

useQuery().invalidate() (signals.ts:1292-1296) is not a substitute — it re-runs one client-side query, not the route's server block.

Prior art

  • Inertia: router.reload({ only: ['goals'] }) — re-runs the server handler and merges just the named props.
  • Remix: useRevalidator().revalidate() — re-runs the current route's loaders, keeping component state.
  • Next.js App Router: router.refresh().
  • Nuxt: refreshNuxtData() / useAsyncData().refresh().
  • Turbo Frames: refresh a named region rather than the document.

Ask

  1. Add router.refresh() (and expose it as a runtime global) that re-requests the current URL as a fragment and swaps it, preserving client signal scopes — i.e. the existing fragment path, with the same-URL early return bypassed internally.
  2. Add a partial form: router.refresh({ only: ['goals'] }), where the server block can declare named sections and the swap replaces only the matching [data-stx-section] containers.
  3. Failing (2), at minimum make force reachable: accept { force: true } in the runtime navigate options object (signals.ts:1057) and forward it to window.stxRouter.navigate as the third argument.
  4. Document which of navigate, refresh and { reload: true } an app should reach for; today { reload: true } is the only one that works and it is the most expensive.

Related: #1807 (navigate's second argument), #1772 (hard reload on build-version skew).

14 days ago

Fixed in cda75d5dab — packages/router/src/client.ts (same commit closed #1850).

Two methods on the router, both also published as bare globals (refresh, invalidateRoute) with declarations in stx.d.ts:

router.refresh()          // re-run the current route's server data
router.invalidate(url?)   // expire one cache entry, default the current page

refresh() is navigate(location.pathname + location.search + location.hash, 'replace', true). The force flag both evicts the cache entry and defeats navigate's same-URL early return, so this exposes behaviour the router already had rather than adding a second code path that could drift from it. 'replace' because a refresh is not a new place — it must not add a history entry you can go Back to.

invalidate(url) expires exactly one entry, so a mutation can invalidate just the page it affected and let the next visit re-fetch, instead of throwing the whole cache away with clearCache().

The point of both: a value that changed server-side — a count in the layout, a list after a POST — previously needed a full document load, which discards every signal on the page. That is the exact thing the SPA exists to avoid, so apps reached for location.reload() and lost their state.

Proven by packages/router/test/route-refresh.test.ts — 8 tests, green.

Sign in to comment on this issue.