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:
Sylvain Duchesne
2026-08-13 09:49:24 +02:00
parent f77317c4d1
commit 55714d0a23
10 changed files with 458 additions and 76 deletions
+8
View File
@@ -0,0 +1,8 @@
# Doc-debt — app-contract
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED docs/api-contract.md @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
@@ -16,8 +16,7 @@ This package covers placement (creating and listing an application's documents b
An application using this package must:
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, so awaiting it before `init` has been called never resolves;
- expect `ensureIdentity()` to mount a full-screen barrier on every top-level load, and the page to reload itself once when a person returns to it: hold no un-persisted state across that call.
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first.
## Surface
@@ -109,6 +108,8 @@ Only a document's owner writes to it. Holding its read key never grants a write.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it never appears. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
`createEntityDoc` throws if the document cannot be recorded in its store.
+10
View File
@@ -0,0 +1,10 @@
# Doc-debt — sign-in
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/src/shared-wallet/bootstrap.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/shared-wallet/access-gate.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/shared-wallet/session.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
+3 -1
View File
@@ -108,7 +108,9 @@ export async function ensureIdentity(): Promise<PrincipalId>; // shared-wallet/
export interface SharedWalletConfig { fileUrl: string; password: string; importUrl?: string }
```
One call, before the application renders. It resolves the identity from the URL (`?ng-id=`), failing that from browser storage, and only if neither answers does it show a barrier: download the shared wallet, here is its password, import it once, and name your space.
One call, before the application renders. It resolves the identity from the URL (`?ng-id=`), failing that from browser storage and top-level it shows the barrier anyway, with whatever it found already in the field: download the shared wallet, here is its password, import it once, and name your space. Knowing who someone is says nothing about whether their browser still holds the wallet, and the barrier is the only place it is handed out. Past the broker round-trip, inside the iframe, a known identifier stands it down.
It takes no timeout and needs none: the session it waits for arrives through this package's `init` and nowhere else, so awaited before that call it throws, naming the call to make first (`shared-wallet/access-gate.ts`, `refuseAWaitNothingCanEnd`).
### Target
@@ -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;
}
+23 -3
View File
@@ -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). */
+125 -42
View File
@@ -8,6 +8,7 @@
import { getCurrentUser } from "../src/shared-wallet/bootstrap";
import { test, expect, afterEach } from "bun:test";
import { configure } from "../src/index";
import { init } from "../src/surface/lifecycle";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { ensureIdentity } from "../src/shared-wallet/access-gate";
@@ -70,11 +71,14 @@ afterEach(() => {
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
});
function configured() {
function configured(injectedInit?: (...args: unknown[]) => unknown) {
configure({
ng: {} as never,
useShape: (() => {}) as never,
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
// Only where a test needs a hand-over to come back FROM: the delegation is what the
// polyfill's `init()` registers, and a page that never delegated was never handed over.
...(injectedInit ? { init: injectedInit } : {}),
});
// AFTER `configure`, which wires the registry onto the package's own session — a session
// that only `init()` can open, and no page here calls it. The substitution is what lets
@@ -283,10 +287,17 @@ function inBrowser(opts: { url: string; storage?: ReturnType<typeof fakeStorage>
location,
storage,
dom,
/** How many times the page asked the browser to reload it. */
/**
* How many times the page asked the browser to reload it — which must stay at zero.
* A reload throws away everything the application holds in memory, and it cannot opt
* out, observe it, or clean up first; the barrier is this package's screen, so putting
* it back is this package's business and nothing else on the page may be disturbed.
*/
get reloads(): number { return reloads; },
/** Is anything listening for the browser restoring this page from its cache? */
get armed(): boolean { return (listeners.get("pageshow") ?? []).length > 0; },
/** How many such listeners — one hand-over or ten, the page needs exactly one. */
get armings(): number { return (listeners.get("pageshow") ?? []).length; },
/**
* The browser restoring this page — from its back/forward cache (`persisted`) or
* loading it afresh. Only the first is a return from a hand-over that did not complete.
@@ -410,84 +421,156 @@ test("inside the iframe, a page that knows NOBODY still asks — the safe failur
// their only way out, and it restores this document from the browser's cache exactly as
// it left — barrier frozen, button dead, `init()` already delegated and never redirecting
// again. Without a reset there is nothing on that page left to press.
//
// The reset used to be `location.reload()`, and it was this package charging its own
// scaffolding to the application: a reload destroys whatever the application held in
// memory, and the application cannot opt out of it, observe it, or clean up first. So the
// barrier — which is entirely ours — is what comes back, and the page is left alone.
//
// Every test below therefore goes through the polyfill's `init()`: the hand-over IS that
// delegation, so a page that never delegated has nothing to come back from, and a fixture
// that played `pageshow` at it would be replaying a state no browser produces.
/**
* A page wired the way an application wires one, with the injected `init` recording what
* the address bar said each time it was handed over — which is what the broker would carry.
*
* It records rather than navigates: a delegation that really navigated would end the test
* (and the document), and what is being judged here is that the SECOND one happens at all.
*/
function handingOverFrom(opts: { url: string; storage?: ReturnType<typeof fakeStorage>; side?: Side }) {
const handOvers: string[] = [];
configured((..._args: unknown[]) => {
handOvers.push(String((globalThis as { location?: { href: string } }).location?.href));
return Promise.resolve();
});
const page = inBrowser(opts);
return { page, handOvers };
}
test("a top-level page that has been handed over holds its barrier, frozen", async () => {
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
const { page, handOvers } = handingOverFrom({ url: APP });
const delegated = init(() => {}, true, []);
page.dom.submit("Gina");
await settled;
await delegated;
expect(handOvers.length).toBe(1);
// Not removed: the hand-over is a navigation, and a bare page is what the person would
// come back to. This is also the state the reset below un-sticks.
// come back to. This is also the state the revival below un-sticks.
expect(page.dom.shown).toBe(true);
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
expect(page.armed).toBe(true);
});
test("restored from the browser's cache, it reloads — the button comes back to life", async () => {
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
test("restored from the browser's cache, the BARRIER comes back to life — the page is not reloaded", async () => {
const { page, handOvers } = handingOverFrom({ url: APP });
const delegated = init(() => {}, true, []);
page.dom.submit("Gina");
await settled;
await delegated;
expect(handOvers.length).toBe(1);
page.pageshow(true);
// A reload re-runs `init()`, which settles again and puts the barrier back up prefilled
// from the address bar — the only reset available, since the redirect belongs to the
// level below and has already happened.
expect(page.reloads).toBe(1);
// Nothing outside the barrier is touched: no reload, so whatever the application held in
// memory is still there, and it never had to be told to hold nothing.
expect(page.reloads).toBe(0);
// And the barrier is usable again — the same screen, the field as they left it, the
// button live and labelled to be pressed rather than frozen on what already failed.
expect(page.dom.shown).toBe(true);
expect(page.dom.prefilled).toBe("Gina");
expect(page.dom.button).toEqual({ label: "Entrer", disabled: false });
});
test("a barrier already answered ignores a second Enter — one return listener, one reload", async () => {
// The button is dead by then, and a dead button dispatches no click — but the field
// still takes the key. Twice armed would be twice reloaded on the way back.
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
test("confirming the revived barrier hands the page over AGAIN — which is the way out", async () => {
// The point of reviving it. A live button that led nowhere would be the same dead end
// with a friendlier face: what the person needs is the hand-over to happen again, and
// `init()` cannot do it — it delegated once and its promise is long answered.
const { page, handOvers } = handingOverFrom({ url: APP });
const delegated = init(() => {}, true, []);
page.dom.submit("Gina");
await settled;
await delegated;
page.pageshow(true);
page.dom.confirm();
expect(handOvers).toEqual([APP + "?ng-id=gina", APP + "?ng-id=gina"]);
expect(page.reloads).toBe(0);
// Handing over again freezes it again — a second round-trip is in flight, and the same
// listener is still there for a second return.
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
});
test("someone who comes back to change their identifier leaves with the NEW one", async () => {
// Not a hypothetical: the dead end they came back from is exactly where a person
// discovers they entered the wrong identifier. So the second confirmation is a full
// settling — normalized, in the address bar, and the identity the page now acts as —
// and not a replay of the value that left.
const { page, handOvers } = handingOverFrom({ url: APP });
const delegated = init(() => {}, true, []);
page.dom.submit("Gina");
await delegated;
page.pageshow(true);
page.dom.submit("Hank");
expect(page.location.href).toBe(APP + "?ng-id=hank");
expect(page.storage.getItem(KEY)).toBe("hank");
expect(getCurrentUser()).toBe("hank");
expect(handOvers[1]).toBe(APP + "?ng-id=hank");
expect(page.reloads).toBe(0);
});
test("a barrier handing over ignores a second Enter — one listener, one hand-over", async () => {
// The button is dead by then, and a dead button dispatches no click — but the field
// still takes the key. Twice acted on would be twice handed over, and twice armed would
// leave a listener behind on every round-trip.
const { page, handOvers } = handingOverFrom({ url: APP });
const delegated = init(() => {}, true, []);
page.dom.submit("Gina");
await delegated;
page.dom.pressEnter();
page.pageshow(true);
expect(page.reloads).toBe(1);
expect(handOvers.length).toBe(1);
expect(page.armings).toBe(1);
});
test("an ordinary load is not a return — it must not reload", async () => {
// `pageshow` fires on every load, cached or not. Reloading on the plain one would loop
// the page forever, which is a worse failure than the one being fixed.
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
test("an ordinary load is not a return — the barrier is left frozen", async () => {
// `pageshow` fires on every load, cached or not. Reviving on the plain one would arm the
// button of a page whose hand-over is still on its way out, and offer a second one.
const { page, handOvers } = handingOverFrom({ url: APP });
const delegated = init(() => {}, true, []);
page.dom.submit("Gina");
await settled;
await delegated;
page.pageshow(false);
expect(page.reloads).toBe(0);
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
page.dom.confirm();
expect(handOvers.length).toBe(1);
});
test("someone still AT the barrier is not reset — what they typed survives", async () => {
// Armed on the way OUT, not at load: a person who wandered off before confirming comes
// back to the page they left, not to a reloaded one that lost their identifier. So the
// arming has to straddle the confirmation, and this pins both sides of it.
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
// back to the page they left, with the field as they left it and the button still live.
// So the arming has to straddle the confirmation, and this pins both sides of it.
const { page } = handingOverFrom({ url: APP });
const delegated = init(() => {}, true, []);
expect(page.armed).toBe(false);
// A barrier left unanswered would hold the settling in flight and every later test would
// JOIN it instead of raising its own — so answer it, always.
page.dom.submit("Gina");
await settled;
await delegated;
expect(page.armed).toBe(true);
});
test("inside the iframe nothing is armed — there was no hand-over to come back from", async () => {
configured();
const page = inBrowser({ url: APP, side: "in the broker iframe" });
const settled = ensureIdentity();
const { page } = handingOverFrom({ url: APP, side: "in the broker iframe" });
const delegated = init(() => {}, true, []);
page.dom.submit("Hana");
await settled;
await delegated;
// And the barrier IS removed here: the application renders behind it.
expect(page.dom.shown).toBe(false);
+81
View File
@@ -323,6 +323,87 @@ test("the session id is RELAYED, not rebuilt — what the broker sent is what is
expect(relayed).toBe(1);
});
// ── Awaiting `ensureIdentity()` before `init()` ────────────────────────────
//
// A session arrives through `init()`'s callback and through nothing else, so an application
// that awaits signing in FIRST is waiting for something that cannot happen. It used to wait
// forever — no error, no timeout, the application simply stopped where it awaited — which is
// the worst failure to hand someone integrating. The package owns the session now, so it can
// tell "not yet" from "never" outright, with no timeout, no race and no heuristic delay.
//
// The two below wire the registry the way an APPLICATION does — through `configure` alone,
// which points it at the package's own holder. The route the rest of this file substitutes
// holds a session no `init()` of ours opened, and there a wait legitimately ends.
/** Let pending microtasks and timers run, so "has it settled yet" is a fair question. */
function flush(): Promise<void> {
return new Promise((r) => setTimeout(r, 0));
}
/**
* An application's bootstrap, with the injected `init` kept on a leash.
*
* The real one answers when the BROKER does, which is not the moment it is called so the
* callback is held here rather than fired, which is what makes "`init()` has been called and
* the session has not arrived yet" a state these tests can be IN rather than assume.
*/
function anApplicationThatConfigured() {
let deliver: ((event: unknown) => void) | null = null;
configure({
ng: {} as never,
useShape: (() => {}) as never,
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
init: (...args: unknown[]): Promise<void> => {
const callback = args[0];
if (typeof callback === "function") deliver = callback as (e: unknown) => void;
return Promise.resolve();
},
});
return {
/** The broker answering, at last — `{ status: "loggedin", session }` (`ngweb.js:124`). */
theBrokerAnswers(): void {
if (deliver === null) throw new Error("the injected `init` was never called");
deliver(loggedIn());
},
};
}
test("awaited BEFORE `init()`, signing in fails loudly — it does not wait on a session nobody will open", async () => {
// Inside the iframe, where the barrier stands aside: what is judged here is the wait that
// follows settling, and a barrier nobody answered would hold the call up for its own
// unrelated reason. The error has to name the call to make — a rejection saying only that
// something went wrong would leave the integrator exactly as stuck, just faster.
anApplicationThatConfigured();
inBrowser(APP + "?ng-id=iris", fakeStorage(), "in the broker iframe");
await expect(within(ensureIdentity())).rejects.toThrow(/awaited before init\(\)/i);
});
test("with `init()` called, signing in WAITS for the session — the normal case", async () => {
// The other half, and the reason the refusal cannot be a blanket one: an application calls
// `init()` and then awaits `ensureIdentity()`, and between those two the session genuinely
// has not arrived yet. Waiting there is right, and a refusal that fired here would break
// every application it was meant to help.
// Its OWN identifier: the connection work keys what it has in flight by identity, module
// -wide, so two tests sharing one would let a run left pending by the other be JOINED here
// instead of started — and this one would then be measuring that run, not its own.
const app = anApplicationThatConfigured();
inBrowser(APP + "?ng-id=nora", fakeStorage(), "in the broker iframe");
void init(() => {}, true, []);
const signedIn = ensureIdentity();
let outcome: string | null = null;
void signedIn.then(
(id) => { outcome = `resolved: ${id}`; },
(failure) => { outcome = `rejected: ${String(failure)}`; },
);
await flush();
expect(outcome).toBe(null);
app.theBrokerAnswers();
expect(await within(signedIn)).toBe("nora");
});
test("the package holds the session even when the caller passes NO callback", async () => {
// Upstream the callback is optional (`callback: Function | null`), and an application
// that wants nothing from the lifecycle channel legitimately passes none. The session