fix: trois coûts qui revenaient à l'appelant reviennent au paquet
Le contrat faisait porter à l'application trois choses qui sont des artefacts de notre implémentation, pas de la cible. Le rechargement de page. Au retour depuis le cache du navigateur, la barrière se rechargeait pour rejouer init() — et détruisait au passage l'état de l'application, qui ne pouvait ni s'y opposer ni nettoyer avant. Le paquet détenait pourtant ce qu'il fallait : la fonction init injectée et le callback de l'appelant. Il enregistre désormais sa délégation, ranime sa barrière au retour — champ conservé, bouton réactivé — et redélègue à la confirmation. Rien hors de la barrière n'est touché. Vérifié dans le bundle amont : en page de tête, init navigue à chaque appel, sa garde « une seule fois » ne portant que sur la branche iframe. L'ordre d'appel silencieux. ensureIdentity() attendu avant init() ne se résolvait jamais, sans erreur. Le paquet possédant la session, il distingue maintenant les deux cas sans délai ni heuristique : session pas encore arrivée → il attend ; init jamais appelé → elle n'arrivera pas, il lève en nommant l'appel à faire d'abord. Et la clause qui annonçait la barrière était rangée dans les exigences de déploiement, alors qu'une application n'y peut rien. Elle passe dans les garanties, avec ce qui la remplace : la page n'est jamais rechargée. Il reste deux lignes d'exigences : servir le fichier de portefeuille, et appeler init avant d'attendre l'identité — ce qui échoue désormais bruyamment.
This commit is contained in:
@@ -83,9 +83,12 @@ import {
|
||||
adoptCurrentUser,
|
||||
getConfig,
|
||||
getCurrentUser,
|
||||
getHandOver,
|
||||
getStoreRegistryDeps,
|
||||
normalizeIdentityId,
|
||||
sessionRouteIsThePackages,
|
||||
} from "./bootstrap";
|
||||
import { sessionIsComing } from "./session";
|
||||
import { connectedUser } from "../emulated-verifier/connect";
|
||||
import type { PrincipalId } from "../model/types";
|
||||
|
||||
@@ -158,6 +161,15 @@ export interface SharedWalletConfig {
|
||||
|
||||
const DEFAULT_IMPORT_URL = "https://nextgraph.eu/#/wallet/login";
|
||||
|
||||
/**
|
||||
* The barrier's button, in its two states — and it has exactly two, which is why they are
|
||||
* named here rather than written wherever they are needed. The second one is not decoration:
|
||||
* it is what a person who comes back from the hand-over must NOT find, and what tells the
|
||||
* revived barrier from the frozen one.
|
||||
*/
|
||||
const ENTER = "Entrer";
|
||||
const HANDING_OVER = "Accès en cours…";
|
||||
|
||||
/** The identifier this device already used, from the URL first, then storage. */
|
||||
function storedIdentity(): string | null {
|
||||
try {
|
||||
@@ -219,20 +231,26 @@ function rememberIdentity(id: string): void {
|
||||
* button dead, and an `init()` that has already delegated and will never redirect again.
|
||||
* Nothing on that page can be clicked back to life.
|
||||
*
|
||||
* A reload is the reset, and it is the whole of it: `init()` runs again, settles again,
|
||||
* and puts the barrier back up — prefilled from the address bar the hand-over left behind
|
||||
* — with its button live. Only a restore from the cache does this (`persisted`), and a
|
||||
* reload is not one, so it cannot loop.
|
||||
* **The reset is the barrier's, not the page's.** Reloading was the first answer and it was
|
||||
* this package billing its own scaffolding to the application: a reload destroys whatever
|
||||
* the application held in memory, cannot be opted out of, observed, or cleaned up after —
|
||||
* for a screen that belongs entirely to us. So the restore brings the barrier back to the
|
||||
* state it was in before the confirmation ({@link askForIdentity}, `revive`) and hands the
|
||||
* page over again when the person confirms; everything else on the page is left alone.
|
||||
*
|
||||
* Only a restore from the cache does this (`persisted`); an ordinary load is a page that has
|
||||
* never been handed over, and reviving a barrier nobody froze would arm its button while a
|
||||
* hand-over is on its way out.
|
||||
*
|
||||
* Armed on the way OUT rather than at load, so it exists exactly on the pages that have
|
||||
* been handed over: someone still typing at the barrier who wanders off and comes back
|
||||
* keeps what they typed instead of having it reloaded away.
|
||||
* keeps the barrier they left, untouched.
|
||||
*/
|
||||
function armReturnFromHandOver(): void {
|
||||
function armReturnFromHandOver(revive: () => void): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("pageshow", (event: PageTransitionEvent) => {
|
||||
if (!event.persisted) return;
|
||||
globalThis.location?.reload();
|
||||
revive();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -299,7 +317,7 @@ function askForIdentity(cfg: SharedWalletConfig, prefill: string): Promise<strin
|
||||
<div class="t">Votre identifiant</div>
|
||||
<input data-testid="ng-identity-input" placeholder="votre identifiant" />
|
||||
<p class="hint">Il identifie votre espace (mis en minuscules).</p>
|
||||
<button class="go" data-testid="ng-identity-enter" disabled>Entrer</button>
|
||||
<button class="go" data-testid="ng-identity-enter" disabled>${ENTER}</button>
|
||||
</div></div>
|
||||
</div></div>`;
|
||||
|
||||
@@ -310,28 +328,64 @@ function askForIdentity(cfg: SharedWalletConfig, prefill: string): Promise<strin
|
||||
// whatever a link carried into the page as HTML.
|
||||
input.value = prefill;
|
||||
const sync = (): void => { go.disabled = input.value.trim().length === 0; };
|
||||
// Answered once, and once only. A disabled button dispatches no click, but the field
|
||||
// still takes an Enter key — so without this a second press would arm a second return
|
||||
// listener and resolve a promise that has already been answered.
|
||||
let entered = false;
|
||||
/**
|
||||
* Is the barrier taking a confirmation right now? A disabled button dispatches no click,
|
||||
* but the field still takes an Enter key — so without this a second press would hand the
|
||||
* page over twice. False from the moment a confirmation is acted on; true again only
|
||||
* where the barrier is brought back to life, below.
|
||||
*/
|
||||
let taking = true;
|
||||
/**
|
||||
* Has the settling promise been answered? Once, ever — it is one question, and `init()`
|
||||
* awaits it once. A later confirmation is a different act (see `enter`).
|
||||
*/
|
||||
let answered = false;
|
||||
/**
|
||||
* Bring the barrier back to the state it was in before the confirmation: the button
|
||||
* live and labelled again, the field holding whatever it held — which is the identifier
|
||||
* this person just failed to get in with, and quite possibly the one they came back to
|
||||
* change. Nothing outside the barrier is touched.
|
||||
*/
|
||||
const revive = (): void => {
|
||||
go.textContent = ENTER;
|
||||
taking = true;
|
||||
sync();
|
||||
input.focus();
|
||||
};
|
||||
const enter = (): void => {
|
||||
const value = input.value.trim();
|
||||
if (!value || entered) return;
|
||||
entered = true;
|
||||
if (!value || !taking) return;
|
||||
taking = false;
|
||||
if (insideBroker()) {
|
||||
// The application renders behind the barrier: the session is already coming, and
|
||||
// this screen has nothing left to say.
|
||||
host.remove();
|
||||
} else {
|
||||
// Top-level, settling is followed by the hand-over — `init()` awaits this and then
|
||||
// navigates. Leaving the page bare for that moment would show a blank application;
|
||||
// worse, coming BACK to a bare page would leave nothing to press. So the barrier
|
||||
// stays up and says what is happening, and the return path is armed.
|
||||
go.disabled = true;
|
||||
go.textContent = "Accès en cours…";
|
||||
armReturnFromHandOver();
|
||||
resolve(value);
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
// Top-level, a confirmation is followed by the hand-over — `init()` awaits this and
|
||||
// then navigates. Leaving the page bare for that moment would show a blank
|
||||
// application; worse, coming BACK to a bare page would leave nothing to press. So the
|
||||
// barrier stays up and says what is happening.
|
||||
go.disabled = true;
|
||||
go.textContent = HANDING_OVER;
|
||||
if (!answered) {
|
||||
answered = true;
|
||||
armReturnFromHandOver(revive);
|
||||
resolve(value);
|
||||
return;
|
||||
}
|
||||
// A confirmation on a barrier REVIVED by a return from a hand-over that dead-ended.
|
||||
// Nobody awaits the settling promise any more — it was answered on the way out, and
|
||||
// `init()` delegated on it — so this one settles the identifier itself and hands the
|
||||
// page over again: the same three acts {@link resolveIdentity} performs, in the same
|
||||
// order, because the value may well not be the one that left.
|
||||
const normalized = normalizeIdentity(value);
|
||||
rememberIdentity(normalized);
|
||||
adoptCurrentUser(normalized);
|
||||
// Absent only if `init()` was never called — in which case nothing ever handed this
|
||||
// page over, so no browser ever cached it and this line is unreachable.
|
||||
getHandOver()?.();
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") enter(); });
|
||||
@@ -357,10 +411,12 @@ let settling: Promise<PrincipalId> | null = null;
|
||||
* The two things `ensureIdentity()` does have opposite needs. Settling needs the page;
|
||||
* connecting needs the SESSION, which only `init()`'s callback establishes. Fused, they
|
||||
* made the ordering unsolvable: the identifier must reach the address bar BEFORE `init()`
|
||||
* reads it, yet calling `ensureIdentity()` first hangs — the connection half awaits
|
||||
* reads it, yet calling `ensureIdentity()` first cannot work — the connection half awaits
|
||||
* `getSession()`, and the session is what `init()` is on its way to open
|
||||
* (`emulated-verifier/connect.ts:84` → `account-registry.ts:592` → `session()` →
|
||||
* the consumer's thunk → the promise `init()`'s callback resolves).
|
||||
* the consumer's thunk → the promise `init()`'s callback resolves). Fused, that wait simply
|
||||
* hung; today it is refused outright ({@link refuseAWaitNothingCanEnd}), which makes the
|
||||
* fault visible but does not make the order any less real.
|
||||
*
|
||||
* Split, the order stops being an instruction a caller can get wrong: `init()` awaits THIS
|
||||
* half (`surface/lifecycle.ts`), which completes with no session in existence.
|
||||
@@ -486,9 +542,10 @@ async function resolveIdentity(): Promise<PrincipalId> {
|
||||
* be a rule an application had to follow, and following it hung — so the polyfill's `init()`
|
||||
* awaits {@link settleIdentity} itself. This call is safe FROM `init()` ONWARDS: after it, the identity is
|
||||
* already set; alongside it — what an application's bootstrap actually does — it JOINS the
|
||||
* settling in flight rather than raising a second barrier. It is NOT safe strictly BEFORE
|
||||
* `init()`: the connection work it adds awaits a session only `init()`'s callback resolves,
|
||||
* so awaiting it first deadlocks in silence. Either way it goes on to the connection work, which is what it adds.
|
||||
* settling in flight rather than raising a second barrier. Strictly BEFORE `init()` it is
|
||||
* still wrong — the connection work it adds awaits a session only `init()`'s callback
|
||||
* resolves — but it no longer hangs: it THROWS, naming the call to make first
|
||||
* ({@link refuseAWaitNothingCanEnd}). Either way it goes on to the connection work, which is what it adds.
|
||||
*
|
||||
* **It RETURNS the identity it settled**, and that is not a convenience — it is the only
|
||||
* way an application can know who it is. Upstream the question does not arise: an app
|
||||
@@ -500,10 +557,42 @@ async function resolveIdentity(): Promise<PrincipalId> {
|
||||
*/
|
||||
export async function ensureIdentity(): Promise<PrincipalId> {
|
||||
const settled = await settleIdentity();
|
||||
refuseAWaitNothingCanEnd();
|
||||
await connected();
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to start a wait that nothing can ever end — the ordering fault, made loud.
|
||||
*
|
||||
* The connection work below awaits a session, and a session arrives through this package's
|
||||
* `init()` and through nothing else. Awaited BEFORE that call, this used to hang: no error,
|
||||
* no timeout, the application simply stopped where it awaited. That is the worst failure to
|
||||
* hand someone integrating, and it is entirely diagnosable — the package owns the session
|
||||
* now, so it knows whether one is on its way (`./session.ts`).
|
||||
*
|
||||
* Two conditions, and both are needed. `init()` not having run says nothing on its own when
|
||||
* somebody else supplies the session: the library's own suites and the e2e harness route the
|
||||
* registry to a session they hold, and there the wait ends normally. So the refusal fires
|
||||
* only where the wait would truly be unbounded — the package's own holder, with nothing
|
||||
* coming into it.
|
||||
*
|
||||
* Checked AFTER settling, deliberately: an application's bootstrap calls `init()` and
|
||||
* `ensureIdentity()` in the same tick (`examples/notebook/app.ts`), and settling is
|
||||
* asynchronous, so by the time this runs a same-tick `init()` has been recorded. What it
|
||||
* catches is the caller who awaits FIRST — the order the contract names.
|
||||
*/
|
||||
function refuseAWaitNothingCanEnd(): void {
|
||||
if (!sessionRouteIsThePackages()) return;
|
||||
if (sessionIsComing()) return;
|
||||
throw new Error(
|
||||
"[ng-eventually] ensureIdentity() was awaited before init(): it waits for a session, " +
|
||||
"and a session arrives only through this package's `init()` — not the one passed to " +
|
||||
"`configure()` — which has not been called. Call `init(…)` first, then await " +
|
||||
"`ensureIdentity()`.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the connection work — restoring what others shared with this user, draining its
|
||||
* inboxes — and do not resolve until it has actually run.
|
||||
|
||||
@@ -135,12 +135,33 @@ export interface EventuallyConfig {
|
||||
|
||||
let cfg: EventuallyConfig | null = null;
|
||||
let currentUser: PrincipalId | null = null;
|
||||
/**
|
||||
* What hands the page to the broker — the polyfill's `init()` reduced to a thunk, kept so a
|
||||
* page that comes BACK from the hand-over can run it AGAIN ({@link ./access-gate}).
|
||||
*
|
||||
* It lives here rather than in either module that uses it because it is made of the injected
|
||||
* `init` and the caller's arguments, and this is where the injection is: `init()` registers
|
||||
* it (`../surface/lifecycle.ts`), the barrier runs it when someone confirms a second time.
|
||||
* Kept for the life of the page — the return it exists for happens long after the call.
|
||||
*/
|
||||
let handOver: (() => void) | null = null;
|
||||
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
|
||||
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
|
||||
type ResolvedRegistryDeps = Required<
|
||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
||||
>;
|
||||
let registryDeps: ResolvedRegistryDeps | null = null;
|
||||
/**
|
||||
* Does the registry reach the session through the package's OWN holder?
|
||||
*
|
||||
* {@link configure} points it there, and that is what every application gets. The library's
|
||||
* suites and the e2e harness substitute a route of their own
|
||||
* ({@link configureStoreRegistry}) and hold a session no `init()` of this package opened —
|
||||
* so "`init()` was never called, therefore no session can ever arrive" is a true statement
|
||||
* about the package's holder and about nothing else. Recorded at the wiring rather than
|
||||
* asked afterwards: the wiring WRAPS the injected thunk, so it can no longer be recognised.
|
||||
*/
|
||||
let ownSessionRoute = false;
|
||||
/**
|
||||
* The map key of the current identity — deliberately NOT the raw id.
|
||||
*
|
||||
@@ -196,9 +217,33 @@ export function getConfig(): EventuallyConfig {
|
||||
export function resetConfig(): void {
|
||||
cfg = null;
|
||||
currentUser = null;
|
||||
// The hand-over goes with it, for the same reason: it is made of the config's injected
|
||||
// `init`, so leaving it behind would let a revived barrier delegate to the PREVIOUS
|
||||
// application's SDK.
|
||||
handOver = null;
|
||||
resetSharedWalletSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember how this page is handed to the broker. Called by the polyfill's `init()` on its
|
||||
* way through, before it delegates.
|
||||
*
|
||||
* @internal Never published: an application does not perform the hand-over, it calls `init`.
|
||||
*/
|
||||
export function rememberHandOver(delegate: () => void): void {
|
||||
handOver = delegate;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered hand-over, or `null` when `init()` has never been called — in which case
|
||||
* nothing ever navigated, so there is no return from a hand-over to serve.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getHandOver(): (() => void) | null {
|
||||
return handOver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the storeRegistry's dependencies. INTERNAL since 2026-08-07: an application
|
||||
* passes these to {@link configure}, which calls this. Still exported for the library's
|
||||
@@ -223,6 +268,7 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||
}
|
||||
return session;
|
||||
};
|
||||
ownSessionRoute = deps.getSession === sharedWalletSession;
|
||||
registryDeps = {
|
||||
getSession,
|
||||
normalizeId: deps.normalizeId ?? normalizeIdentityId,
|
||||
@@ -240,9 +286,15 @@ export function getStoreRegistryDeps(): ResolvedRegistryDeps {
|
||||
return registryDeps;
|
||||
}
|
||||
|
||||
/** @internal — see {@link ownSessionRoute}. */
|
||||
export function sessionRouteIsThePackages(): boolean {
|
||||
return ownSessionRoute;
|
||||
}
|
||||
|
||||
/** Reset storeRegistry deps (mainly for tests). */
|
||||
export function resetStoreRegistry(): void {
|
||||
registryDeps = null;
|
||||
ownSessionRoute = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,17 @@ import type { RegistrySession } from "./account-registry";
|
||||
let current: RegistrySession | null = null;
|
||||
let announce!: (s: RegistrySession) => void;
|
||||
let arrival = openArrival();
|
||||
/**
|
||||
* Has `init()` been called — is a session on its way at all?
|
||||
*
|
||||
* {@link sharedWalletSession} WAITS, and waiting is only right while a session can still
|
||||
* arrive. Before `init()` has been delegated to, nothing in the system will ever open one:
|
||||
* the wait is unbounded and answerless, which is the worst shape a failure can take — no
|
||||
* error, no timeout, the application simply stops where it awaited. The polyfill's `init()`
|
||||
* notes itself here as it goes through (`surface/lifecycle.ts`), and that one fact separates
|
||||
* "not yet" from "never" with no timeout, no race and no guessing.
|
||||
*/
|
||||
let expected = false;
|
||||
|
||||
function openArrival(): Promise<RegistrySession> {
|
||||
return new Promise<RegistrySession>((resolve) => {
|
||||
@@ -52,6 +63,28 @@ export function sharedWalletSession(): Promise<RegistrySession> {
|
||||
return current !== null ? Promise.resolve(current) : arrival;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that `init()` has been called — a session is on its way. See {@link expected}.
|
||||
*
|
||||
* @internal Called by the polyfill's `init()`, and by nothing else: it is a statement about
|
||||
* that call having happened, not a switch anyone may set.
|
||||
*/
|
||||
export function expectSession(): void {
|
||||
expected = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can a session still arrive through this holder — because one is already here, or because
|
||||
* `init()` has been called and will deliver one?
|
||||
*
|
||||
* A caller that awaits when this answers `false` waits forever. It is deliberately not a
|
||||
* refusal by itself: the holder's job is to answer, and who should refuse is whoever is
|
||||
* about to wait ({@link ./access-gate}.ensureIdentity).
|
||||
*/
|
||||
export function sessionIsComing(): boolean {
|
||||
return current !== null || expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a lifecycle event, and keep the session if it carries one.
|
||||
*
|
||||
@@ -111,4 +144,7 @@ export function captureSession(event: unknown): boolean {
|
||||
export function resetSharedWalletSession(): void {
|
||||
current = null;
|
||||
arrival = openArrival();
|
||||
// Including the expectation: it was set by an `init()` on the config being discarded, and
|
||||
// a suite that reset and then awaited must be told the truth about the NEXT one.
|
||||
expected = false;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
* (`shared-wallet/access-gate.ts`, {@link settleIdentity}).
|
||||
*/
|
||||
|
||||
import { getConfig } from "../shared-wallet/bootstrap";
|
||||
import { getConfig, rememberHandOver } from "../shared-wallet/bootstrap";
|
||||
import { settleIdentity } from "../shared-wallet/access-gate";
|
||||
import { captureSession } from "../shared-wallet/session";
|
||||
import { captureSession, expectSession } from "../shared-wallet/session";
|
||||
|
||||
/**
|
||||
* Forwards to the real `@ng-org/web` `init`, once the identifier is in the address bar.
|
||||
@@ -66,7 +66,27 @@ export function init(...args: any[]): any {
|
||||
captureSession(event);
|
||||
return typeof callback === "function" ? callback(event) : undefined;
|
||||
};
|
||||
return settleIdentity().then(() => f(listen, ...rest));
|
||||
const delegate = (): unknown => f(listen, ...rest);
|
||||
// Two notes taken on the way in, both about what only this call can know.
|
||||
//
|
||||
// The hand-over, first: this delegation is the whole of it, and a person who reaches the
|
||||
// broker without a wallet comes back to THIS document with it already spent. The barrier
|
||||
// revives there and needs something to hand the page over with — this, exactly as it
|
||||
// would have run the first time (`shared-wallet/access-gate.ts`).
|
||||
rememberHandOver((): void => {
|
||||
// Un-awaited, and it has to be: the promise this call returned was answered on the way
|
||||
// out, so a rejection here has no caller to reach. Reported rather than dropped as an
|
||||
// unhandled rejection — the page is on its way to the broker either way.
|
||||
void Promise.resolve(delegate()).catch((failure: unknown) => {
|
||||
console.error("[ng-eventually] the hand-over to the broker failed", failure);
|
||||
});
|
||||
});
|
||||
// And that it RAN. The session arrives through this call and through no other, so
|
||||
// "`init()` has not been called" is the same statement as "no session can ever arrive" —
|
||||
// which is what lets `ensureIdentity()` refuse an impossible wait instead of hanging on it
|
||||
// (`shared-wallet/session.ts`, `shared-wallet/access-gate.ts`).
|
||||
expectSession();
|
||||
return settleIdentity().then(delegate);
|
||||
}
|
||||
|
||||
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
|
||||
|
||||
Reference in New Issue
Block a user