Skip to content

Warmup importmap-hash instability hard-reloads and wipes form input after deploy #146

Description

@vivek7405

Problem

After a fresh deploy, during the first-request warmup window, typing into a
form (e.g. the blog signup Name/email) and then navigating or submitting
triggers a full-page hard reload that wipes focus and the typed input. It
settles once the server is warm. This is a regression from #143
(runtime-first boot).

Root cause

The client router hard-reloads (location.assign) when a navigation's
importmap build id differs from the current page's, to avoid resolving
modules against a stale importmap after a real deploy
(router-client.js applySwap). That build id is importMapHash(), stamped
into data-webjs-build and the X-Webjs-Build header (ssr.js).
importMapHash() is only set inside setVendorEntries (importmap.js) and
returns '' until vendor resolves.

Before #143 the importmap resolved at boot, so the hash was final and frozen
before the server accepted any request. #143 deferred resolution to the first
request and made vendor async + retryable, so the served hash now changes
across the warmup window:

  • startServer fires warmup() on listen, which sets vendorAttemptedOnce
    before awaiting the resolve; a real request arriving in that window takes
    the non-blocking bypass and serves SSR with importMapHash() === ''.
  • The page loads with data-webjs-build="". Vendor finishes; the hash flips.
  • The next navigation/submit returns the real build id; the router sees the
    change (empty -> non-empty falls through to a textContent compare that also
    differs) and does a full location.assign, wiping the form.
  • A transient/partial first resolve followed by a successful retry produces
    two different NON-empty hashes, hitting the same reload even without the
    concurrency bypass.

The invariant #143 broke: within one process, the advertised build id must
never change after the first response.

Fix

  1. Advertise the build id only when the importmap is final (authoritatively
    resolved). A partial / still-warming map is served with no build id, so it
    can never present two different non-empty ids across the window.
  2. Router: an absent/empty build id means do not hard-reload (suppress the
    textContent fallback too), so any not-yet-final window is reload-safe.
  3. Pinned apps resolve the build id at boot from the committed
    .webjs/vendor/importmap.json (cheap deterministic file read, no analysis,
    no network), mirroring Rails importmap. The recommended posture then has a
    stable id from the first response and correct cross-deploy detection with
    zero warmup exposure. All expensive analysis (graph, scan, gate, elision,
    unpinned jspm resolve) stays deferred.

Acceptance criteria

  • Within a process, the advertised build id never changes after the first response
  • A warming response carries no build id, and the router never hard-reloads against an empty id
  • Pinned app: build id is stable and non-empty from the first response (computed at boot)
  • Unpinned app: warmup is reload-safe (no input loss); build id publishes once, stably, after first resolve
  • Regression test reproduces the empty -> non-empty hash flip and proves no hard reload
  • Real cross-deploy still hard-reloads (deploy detection preserved)
  • Docs / AGENTS updated (build-id stability invariant; boot now also does the pinned-file read)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions