Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
59c77be
docs(plugin-history-sync): add preventDefault reconciler solution pla…
ENvironmentSet Jun 28, 2026
437365f
test(e2e): add history-sync × preventDefault verification harness (FE…
ENvironmentSet Jun 29, 2026
100a62f
test(e2e): strengthen navigability/window witnesses (FEP-2001 review …
ENvironmentSet Jun 29, 2026
d64e38d
feat(plugin-history-sync): drive the browser from the committed stack
ENvironmentSet Jun 29, 2026
c6d8858
test(plugin-history-sync): settle the jsdom suite on idle; scope out …
ENvironmentSet Jun 29, 2026
7057e6d
refactor(plugin-history-sync): remove comments per review
ENvironmentSet Jun 30, 2026
b074101
refactor(plugin-history-sync): rename controller step actions to v2 n…
ENvironmentSet Jun 30, 2026
3c311d4
fix(plugin-history-sync): reset inFlight on dispose
ENvironmentSet Jun 30, 2026
f5b5c36
fix(plugin-history-sync): throw when start() is called twice
ENvironmentSet Jun 30, 2026
fe7b626
refactor(plugin-history-sync): default useHash at construction
ENvironmentSet Jun 30, 2026
9adcca4
fix(plugin-history-sync): move inFlight reset into dispose
ENvironmentSet Jun 30, 2026
fff822e
fix(plugin-history-sync): throw on missing browser ordinal in syncPass
ENvironmentSet Jun 30, 2026
580fb8e
refactor(plugin-history-sync): drop redundant zIndex sort of committe…
ENvironmentSet Jun 30, 2026
1a22674
refactor(plugin-history-sync): track pending sync explicitly across i…
ENvironmentSet Jun 30, 2026
952e2c2
fix(plugin-history-sync): throw on empty entered stack in syncPass
ENvironmentSet Jun 30, 2026
9360e09
refactor(plugin-history-sync): narrow isEntered to enter-done
ENvironmentSet Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 174 additions & 6 deletions .pnp.cjs

Large diffs are not rendered by default.

Binary file not shown.
Binary file not shown.
Binary file added .yarn/cache/fsevents-patch-19706e7e35-10.zip
Binary file not shown.
Binary file not shown.
Binary file not shown.
4 changes: 4 additions & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist
node_modules
*.log
test-results
106 changes: 106 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# `@stackflow/plugin-history-sync` × `preventDefault` verification harness

A safety net that proves `@stackflow/plugin-history-sync` coexists correctly
with `preventDefault`-consuming plugins (`@stackflow/plugin-blocker`). It drives
a dedicated app with **both plugins applied** and asserts the one guarantee that
matters at every quiet point: **browser == stack** — the visible screen, the URL
and the public `getStack()` snapshot all agree.

The four desyncs this guards against:

1. A blocked **browser back** must keep the user in place (today the back is
dispatched directly and cannot be vetoed).
2. A blocked **programmatic** pop/stepPop/replace must not move the browser
(today a queued `history.back()` runs anyway).
3. A blocked **browser forward** must restore, and a following push must still
sync exactly (today a leaked counter skips the sync).
4. The above must hold **regardless of plugin registration order**.

## Tiers

| Tier | Runner | Environment | Scope |
|---|---|---|---|
| **T1** | jest (`node`) driving real Chromium via the `playwright` library | production build served by an in-process vite preview | all real-history behaviors: the four problems, the coexistence contract, concurrency, and both plugins' navigation-observable cases |
| **T2i** | jest (`jsdom`) | both plugins applied in-process | timing-independent blocker-internal contracts (error isolation, notification order) |

Both tiers run the **current source** of the plugins (the workspace packages are
aliased to their `src`), so the harness reproduces today's behavior and will pick
up a product fix immediately.

## Running

```bash
# one-time: download the Chromium build used by T1
yarn workspace @stackflow/e2e-history-sync-blocker browser:install
# (or set HARNESS_BROWSER_CHANNEL=chrome to use a system Chrome)

# both tiers
yarn workspace @stackflow/e2e-history-sync-blocker test

# one tier
yarn workspace @stackflow/e2e-history-sync-blocker test:t1
yarn workspace @stackflow/e2e-history-sync-blocker test:t2i

# explore the app by hand (same app the drivers use)
yarn workspace @stackflow/e2e-history-sync-blocker app:dev
```

T1 builds the app and serves it automatically (jest `globalSetup`); no separate
server step is needed.

## Expected red on the unfixed product

This harness encodes the **target** behavior. Against the current, unfixed
`plugin-history-sync` the cases that exercise a vetoed navigation that touches
history are **red** — that is correct and expected:

- the four-problem cases;
- the coexistence-contract cases that block a pop and the blocker cases that
block a pop/stepPop (the URL desyncs from the committed stack);
- the concurrency cases whose consistency depends on the fix.

The baseline navigation suite (history-sync behaviors with the blocker present
but disarmed) is **green** — it proves the harness models the system correctly,
so the reds are genuine product desyncs rather than harness faults. The cases
that don't touch a vetoed backward navigation (allowed navigations, blocked
pushes/replaces with no history side effect, the blocker-internal contracts) are
also green. When the product upholds browser == stack across `preventDefault`,
the whole gate turns green.

## What is and isn't asserted

Tests assert only externally observable behavior:

- **SCREEN** — the visible activity/step (DOM markers).
- **URL** — `window.location`.
- **STACK** — the public `getStack()` snapshot (top activity, params, steps).
- **NAVIGABILITY** — where `browserBack`/`browserForward` reach at rest.
- **Harness-owned signals** — the blocker's own `shouldBlock`/`onBlocked`
notifications and `onError` sink, and the probe co-plugin's own hook calls.

Internal coordinates (history `state` ordinals, suppression tokens, the sync
queue, history-sync's own before/after hooks) are never read. Settle is observed,
never slept for: a step is done only once the public transition state is idle and
a double-stable check (two snapshots separated by ≥1 animation frame + 1
macrotask) agrees.

## Layout

```
src/
shared/contract.ts the observation contract: test ids, query knobs, bridge shape
app/ the harness app (both plugins, controls, blocker UI, probe, bridge)
dal/ Driver Abstraction Layer over a Chromium page + per-file fixture
t1/ real-browser specs (problems, compat, concurrency, history-sync, blocker)
t2i/ jsdom integration spec (blocker-internal contracts)
```

The app is configured entirely by URL query knobs (`order`, `hash`, `lazyDelay`,
`block`, `blockers`, `blockAsync`, `probe`, …), so each scenario is a pure
function of how the driver opened it. See `src/shared/contract.ts`.

> Note: `yarn typecheck` covers the harness's own code (`src/app`, `src/dal`,
> `src/shared`). Because the harness compiles the plugins' current **source**,
> `tsc` additionally surfaces a few strict-mode diagnostics inside that aliased
> product source; those are not harness-code issues. The gate is the test suites
> and the production build.
11 changes: 11 additions & 0 deletions e2e/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<title>@stackflow history-sync × blocker harness</title>
<meta charset="utf-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>
55 changes: 55 additions & 0 deletions e2e/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Both gate tiers run under jest (which resolves cleanly under this repo's Yarn
* PnP setup):
*
* - t1 : real Chromium. A `node` environment drives the harness app through
* the `playwright` library. A built app is served by an in-process
* vite preview started in globalSetup.
* - t2i : jsdom integration. Both plugins applied; timing-independent
* blocker-internal contracts (error isolation, notification order).
*
* Both map the @stackflow/* packages to their `src` so the suites exercise the
* current source, not a built dist.
*/

const swcTransform = [
"@swc/jest",
{
jsc: {
transform: { react: { runtime: "automatic" } },
},
},
];

const stackflowSrc = {
"^@stackflow/core$": "<rootDir>/../core/src/index.ts",
"^@stackflow/react$": "<rootDir>/../integrations/react/src/index.ts",
"^@stackflow/config$": "<rootDir>/../config/src/index.ts",
"^@stackflow/plugin-history-sync$":
"<rootDir>/../extensions/plugin-history-sync/src/index.ts",
"^@stackflow/plugin-blocker$":
"<rootDir>/../extensions/plugin-blocker/src/index.ts",
"^@stackflow/plugin-renderer-basic$":
"<rootDir>/../extensions/plugin-renderer-basic/src/index.ts",
};

module.exports = {
projects: [
{
displayName: "t1",
testEnvironment: "node",
testMatch: ["<rootDir>/src/t1/**/*.spec.ts"],
transform: { "^.+\\.(t|j)sx?$": swcTransform },
setupFilesAfterEnv: ["<rootDir>/src/t1/setup.cjs"],
globalSetup: "<rootDir>/src/t1/globalSetup.cjs",
globalTeardown: "<rootDir>/src/t1/globalTeardown.cjs",
},
{
displayName: "t2i",
testEnvironment: "jsdom",
testMatch: ["<rootDir>/src/t2i/**/*.spec.tsx"],
transform: { "^.+\\.(t|j)sx?$": swcTransform },
moduleNameMapper: stackflowSrc,
},
],
};
45 changes: 45 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@stackflow/e2e-history-sync-blocker",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"description": "Real-browser + jsdom verification harness proving @stackflow/plugin-history-sync coexists safely with preventDefault consumers (@stackflow/plugin-blocker).",
"scripts": {
"app:dev": "vite",
"app:build": "vite build",
"app:preview": "vite preview",
"browser:install": "playwright install chromium",
"test": "jest",
"test:t1": "jest --selectProjects t1",
"test:t2i": "jest --selectProjects t2i",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist node_modules/.vite"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@stackflow/config": "^2.0.0",
"@stackflow/core": "^2.0.0",
"@stackflow/plugin-blocker": "^0.1.1",
"@stackflow/plugin-history-sync": "^1.11.0",
"@stackflow/plugin-renderer-basic": "^1.1.14",
"@stackflow/react": "^2.0.0",
"@swc/core": "^1.6.6",
"@swc/jest": "^0.2.36",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"history": "^5.3.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rimraf": "^3.0.2",
"typescript": "^5.5.3",
"vite": "^5.3.2"
}
}
43 changes: 43 additions & 0 deletions e2e/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Harness app root. No StrictMode (and served as a production build) so that
* development-mode double-invocation never perturbs the fallback count or the
* settle observation.
*/

import { useEffect, useMemo } from "react";
import { markReady } from "./bridge";
import { buildStack } from "./buildStack";
import { LifecyclePanel } from "./components/LifecyclePanel";
import { HarnessConfigContext } from "./HarnessConfigContext";
import { getCoreActions } from "./plugins/spyPlugin";
import { parseHarnessConfig, readHarnessSearch } from "./query";

export function App() {
const harnessConfig = useMemo(
() => parseHarnessConfig(readHarnessSearch()),
[],
);
const { Stack } = useMemo(() => buildStack(harnessConfig), [harnessConfig]);

useEffect(() => {
// Signal readiness once the initial route has reached idle.
let raf = 0;
const check = () => {
const actions = getCoreActions();
if (actions && actions.getStack().globalTransitionState === "idle") {
markReady();
return;
}
raf = requestAnimationFrame(check);
};
check();
return () => cancelAnimationFrame(raf);
}, []);

return (
<HarnessConfigContext.Provider value={harnessConfig}>
<Stack />
<LifecyclePanel />
</HarnessConfigContext.Provider>
);
}
13 changes: 13 additions & 0 deletions e2e/src/app/HarnessConfigContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext, useContext } from "react";
import type { HarnessConfig } from "./query";

/** Injects the per-instance harness configuration to activities. */
export const HarnessConfigContext = createContext<HarnessConfig | null>(null);

export function useHarnessConfig(): HarnessConfig {
const config = useContext(HarnessConfigContext);
if (!config) {
throw new Error("HarnessConfigContext is not provided");
}
return config;
}
39 changes: 39 additions & 0 deletions e2e/src/app/activities/activities.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* The harness activities. All are plain screens; their navigation/step
* behavior comes entirely from history-sync + the controls. Lazy's loading
* window is produced by an async loader wired in the config, not by the
* component itself.
*/

import type { ActivityComponentType } from "@stackflow/react";
import { Screen } from "../components/Screen";

declare module "@stackflow/config" {
interface Register {
Home: Record<string, never>;
Article: { articleId: string; title?: string };
Third: { thirdId: string };
Fourth: { fourthId: string };
Lazy: Record<string, never>;
}
}

export const Home: ActivityComponentType<"Home"> = () => (
<Screen activityName="Home" />
);

export const Article: ActivityComponentType<"Article"> = () => (
<Screen activityName="Article" />
);

export const Third: ActivityComponentType<"Third"> = () => (
<Screen activityName="Third" />
);

export const Fourth: ActivityComponentType<"Fourth"> = () => (
<Screen activityName="Fourth" />
);

export const Lazy: ActivityComponentType<"Lazy"> = () => (
<Screen activityName="Lazy" />
);
66 changes: 66 additions & 0 deletions e2e/src/app/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* The `window.__harness__` instrumentation bridge. It exposes only the public
* stack snapshot, the browser location, and harness-owned observations
* (blocker notifications, probe hook calls, the error sink, the fallback
* count). It never reveals history-sync internals.
*/

import type {
HarnessBridge,
LocationView,
StackActivityView,
StackView,
} from "../shared/contract";
import { harnessStore } from "./harnessStore";
import { getCoreActions } from "./plugins/spyPlugin";

function serializeStack(): StackView {
const actions = getCoreActions();
if (!actions) {
return { globalTransitionState: "loading", activities: [], active: null };
}
const stack = actions.getStack();
const activities: StackActivityView[] = stack.activities
.filter((a) => a.transitionState !== "exit-done")
.map((a) => {
const steps = a.steps ?? [];
const topStep = steps[steps.length - 1];
return {
name: a.name,
params: a.params,
transitionState: a.transitionState,
isActive: a.isActive,
stepCount: steps.length,
stepParams: topStep ? topStep.params : a.params,
};
});
const active = activities.find((a) => a.isActive) ?? null;
return {
globalTransitionState: stack.globalTransitionState,
activities,
active,
};
}

function readLocation(): LocationView {
const { href, pathname, search, hash } = window.location;
return { href, pathname, search, hash };
}

const bridge: HarnessBridge = {
ready: false,
getStack: serializeStack,
getLocation: readLocation,
getFallbackCallCount: () => harnessStore.fallbackCount,
getBlockerLog: () => harnessStore.blockerLog.slice(),
getProbeLog: () => harnessStore.probeLog.slice(),
getErrors: () => harnessStore.errors.slice(),
};

export function installBridge() {
window.__harness__ = bridge;
}

export function markReady() {
bridge.ready = true;
}
Loading
Loading