Wave 0 counted pushState across html/ and found 0. Every page read its query
string once at boot and never wrote one again, so you could not send anyone a
link to WP07, a refresh dropped you back at the default view, and Back left the
app entirely because the app had never added a history entry.
CR-011 and CR-014 both promise an email carrying a direct link to a work package.
That is X1, and it was blocked on this. It is not blocked now.
html/wp-url.js is the whole mechanism, and it is deliberately NOT a router.
Nothing in it intercepts navigation or renders anything; it is the query string
treated as state that can be read, merged, written and subscribed to. Pages keep
their own rendering. Query parameters rather than a hash, because the server
already serves these paths and a hash is never sent to the server - which matters
the day a link has to be resolved before the page boots.
The merge behaviour is the part that earns its place: WPUrl.push({wp:id}) keeps
the active project, and WPUrl.push({wp:''}) clears one key without needing to know
what else is in the URL. Hand-built URLs losing ?project= is the usual way this
goes wrong.
WIRED: the creator (open package, dashboard view), the SOP wizard (tool, step),
the launcher (project). Each records a history entry only when the user chose the
change - restoring from the URL uses replace, or Back would immediately add an
entry and appear to do nothing.
WPUrl.absolute() is what CR-011/CR-014 will paste into an email in wave 8.
TWO BUGS THIS TASK CREATED AND FIXED, both found by the probe rather than by
reading:
- bootSOP() calls newPackage() during boot, and newPackage() cleared ?wp=. A
deep link therefore worked and then erased its own parameter, leaving Back
with nothing to return to. Now guarded on wpCreatorReady.
- goToStep() runs validateStep(), which ends in alert() when a required field
is empty - always true on a freshly loaded page. So restoring ?step=3 from a
shared link opened a modal dialog mid-boot, and hung the browser under CDP.
Restoring a view is not a forward navigation and no longer runs the
forward-navigation guard.
The second one is worth keeping in mind for the rest of wave 4: this app has 79
native dialogs, and any of them firing during a restore path will hang a headless
browser rather than fail visibly.
VERIFICATION. tests/url_state_check.py, 23 checks, all passing, covering every
done-when on the task:
- a URL identifying a work package opens that package
- the same URL for a SIGNED-OUT user goes to login, carries the target through
?next=, and lands on the work package itself after signing in
- refresh preserves project, package, tab and view
- Back and Forward move through states, verified as still-initialised rather
than reloaded, and with the dashboard actually rendered rather than only the
URL changed
- a different user opening the same URL reaches the same view
- nothing credential-shaped appears in the query string
Metric 8, pushState: was 0 at wave 0, now 2 in html/ (one pushState and one
replaceState, both in wp-url.js) behind 6 call sites across 4 files. The raw
count stays low by design - one place writes history, which is the same reason
the token work put one place in charge of colour.
browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, aggregates 16/16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
4.7 KiB
JavaScript
127 lines
4.7 KiB
JavaScript
/* Addressable state — S3 / T4.2.
|
|
---------------------------------------------------------------------------
|
|
Before this file there was no pushState anywhere in the suite. Every page read
|
|
its query string once at boot and never wrote one again, so:
|
|
|
|
• you could not send anyone a link to WP07 — the URL said the same thing
|
|
whatever you were looking at;
|
|
• a refresh dropped you back at the default view;
|
|
• Back left the app entirely, because the app had never added a history entry.
|
|
|
|
CR-011 and CR-014 both promise an email containing a direct link to a work
|
|
package (X1). Those emails cannot exist until a work package has an address,
|
|
which is what this provides.
|
|
|
|
WHAT IT IS NOT: a router. Nothing here intercepts navigation or renders
|
|
anything. It is the query string, treated as state that can be read, merged,
|
|
written and subscribed to. Pages keep their own rendering.
|
|
|
|
Query parameters, not a hash: the server serves these paths already, so a hash
|
|
would be a workaround for a problem this app does not have, and hashes are not
|
|
sent to the server — which matters the day a link needs to be resolved before
|
|
the page boots.
|
|
|
|
Nothing secret goes in the URL. It is copied into emails, chat and tickets.
|
|
*/
|
|
(function (window, document) {
|
|
'use strict';
|
|
|
|
var listeners = [];
|
|
var LAST = serialize(current());
|
|
|
|
function current() {
|
|
var out = {};
|
|
try {
|
|
new URLSearchParams(window.location.search).forEach(function (v, k) { out[k] = v; });
|
|
} catch (e) {}
|
|
return out;
|
|
}
|
|
|
|
function serialize(state) {
|
|
var keys = Object.keys(state).filter(function (k) {
|
|
return state[k] !== '' && state[k] != null && state[k] !== false;
|
|
}).sort();
|
|
var sp = new URLSearchParams();
|
|
keys.forEach(function (k) { sp.set(k, String(state[k])); });
|
|
return sp.toString();
|
|
}
|
|
|
|
// Merge a patch over the current state. Undefined/null/'' removes a key, so a
|
|
// caller can clear `wp` without having to know what else is in the URL — the
|
|
// usual reason ad-hoc URL building loses the active project.
|
|
function merge(patch) {
|
|
var next = current();
|
|
Object.keys(patch || {}).forEach(function (k) {
|
|
var v = patch[k];
|
|
if (v === undefined || v === null || v === '' || v === false) delete next[k];
|
|
else next[k] = v;
|
|
});
|
|
return next;
|
|
}
|
|
|
|
function href(patch) {
|
|
var qs = serialize(merge(patch));
|
|
return window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
|
|
}
|
|
|
|
function apply(patch, opts) {
|
|
opts = opts || {};
|
|
var next = merge(patch);
|
|
var qs = serialize(next);
|
|
if (qs === LAST && !opts.force) return false; // nothing to record
|
|
var url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
|
|
try {
|
|
if (opts.replace) window.history.replaceState({ wpurl: qs }, '', url);
|
|
else window.history.pushState({ wpurl: qs }, '', url);
|
|
} catch (e) {
|
|
return false; // file:// and the like
|
|
}
|
|
LAST = qs;
|
|
return true;
|
|
}
|
|
|
|
function notify(state, viaPop) {
|
|
listeners.forEach(function (fn) {
|
|
try { fn(state, viaPop); } catch (e) { /* one bad subscriber must not stop the rest */ }
|
|
});
|
|
}
|
|
|
|
window.addEventListener('popstate', function () {
|
|
LAST = serialize(current());
|
|
notify(current(), true);
|
|
});
|
|
|
|
window.WPUrl = {
|
|
// Read one parameter, or everything.
|
|
get: function (name) { var s = current(); return name == null ? s : (s[name] || ''); },
|
|
all: current,
|
|
|
|
/* Record a state change in history. Merges over what is already there.
|
|
WPUrl.push({ wp: id }) -> new history entry, Back returns
|
|
WPUrl.push({ wp: '' }) -> clears it
|
|
WPUrl.replace({ view: 'form' }) -> corrects the URL without a new entry
|
|
replace() is for normalising on load or for a change the user did not ask
|
|
for; push() is for one they did, because Back should undo exactly the
|
|
things they chose to do. */
|
|
push: function (patch) { return apply(patch, { replace: false }); },
|
|
replace: function (patch) { return apply(patch, { replace: true }); },
|
|
|
|
// A URL string for the same merge, without navigating. For hrefs and for the
|
|
// links that go into CR-011 / CR-014 emails.
|
|
href: href,
|
|
absolute: function (patch) {
|
|
return window.location.origin + href(patch);
|
|
},
|
|
|
|
/* Subscribe to state changes. Called on Back/Forward with viaPop === true.
|
|
Returns an unsubscribe function. */
|
|
onChange: function (fn) {
|
|
listeners.push(fn);
|
|
return function () {
|
|
var i = listeners.indexOf(fn);
|
|
if (i >= 0) listeners.splice(i, 1);
|
|
};
|
|
},
|
|
};
|
|
})(window, document);
|