Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 22 additions & 4 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,11 @@ jobs:
- project: production
shard: 2
shardTotal: 2
- project: preload-dev
config: playwright.preload.config.ts
- project: preload-production
config: playwright.preload.config.ts
noDeps: true
# HMR tests run serially (workers=1) to avoid filesystem interference.
# hmr-routes (route-types-hmr.test.ts) is intentionally absent: it is
# test.skip(isCI)'d because the file watcher is unreliable on GitHub
Expand Down Expand Up @@ -570,6 +575,10 @@ jobs:
# HOST_ONLY in playwright.config.ts, skips the heavy test-app servers).
RANGO_E2E_HOST: ${{ matrix.project == 'host' && '1' || '' }}
run: |
CONFIG_FLAG=""
if [ -n "${{ matrix.config }}" ]; then
CONFIG_FLAG="--config=${{ matrix.config }}"
fi
WORKERS_FLAG=""
if [ -n "${{ matrix.workers }}" ]; then
WORKERS_FLAG="--workers=${{ matrix.workers }}"
Expand All @@ -578,13 +587,13 @@ jobs:
# so skip dependency projects (dev, build, warmup) that would
# re-run hundreds of unrelated tests and blow the timeout.
NO_DEPS_FLAG=""
if [[ "${{ matrix.project }}" == hmr-* ]]; then
if [ -n "${{ matrix.noDeps }}" ] || [[ "${{ matrix.project }}" == hmr-* ]]; then
NO_DEPS_FLAG="--no-deps"
fi
if [ -n "${{ matrix.shard }}" ]; then
pnpm --filter @rangojs/router test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/${{ matrix.shardTotal }} $WORKERS_FLAG $NO_DEPS_FLAG
pnpm --filter @rangojs/router test $CONFIG_FLAG --project=${{ matrix.project }} --shard=${{ matrix.shard }}/${{ matrix.shardTotal }} $WORKERS_FLAG $NO_DEPS_FLAG
else
pnpm --filter @rangojs/router test --project=${{ matrix.project }} $WORKERS_FLAG $NO_DEPS_FLAG
pnpm --filter @rangojs/router test $CONFIG_FLAG --project=${{ matrix.project }} $WORKERS_FLAG $NO_DEPS_FLAG
fi

- name: Upload test results
Expand Down Expand Up @@ -639,6 +648,11 @@ jobs:
shard: 2
shardTotal: 2
noDeps: true
- project: preload-dev
config: playwright.preload.config.ts
- project: preload-production
config: playwright.preload.config.ts
noDeps: true
# HMR runs in its own job: --no-deps (its deps are the full dev +
# production suites) and workers=1. workers=1 is load-bearing, not
# cosmetic: hmr.test.ts and hmr-route-mutations.test.ts both git-checkout
Expand Down Expand Up @@ -682,6 +696,10 @@ jobs:
env:
TEST_DEBUG: "1"
run: |
CONFIG_FLAG=""
if [ -n "${{ matrix.config }}" ]; then
CONFIG_FLAG="--config=${{ matrix.config }}"
fi
SHARD_FLAG=""
if [ -n "${{ matrix.shard }}" ]; then
SHARD_FLAG="--shard=${{ matrix.shard }}/${{ matrix.shardTotal }}"
Expand All @@ -697,7 +715,7 @@ jobs:
if [ -n "${{ matrix.noDeps }}" ]; then
NO_DEPS_FLAG="--no-deps"
fi
pnpm --filter cloudflare-basic test:e2e --project=${{ matrix.project }} $SHARD_FLAG $WORKERS_FLAG $NO_DEPS_FLAG
pnpm --filter cloudflare-basic test:e2e $CONFIG_FLAG --project=${{ matrix.project }} $SHARD_FLAG $WORKERS_FLAG $NO_DEPS_FLAG

- name: Upload test results
uses: actions/upload-artifact@v6
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"test:unit": "pnpm -r --if-present run test:unit",
"test:unit:rsc": "pnpm -r --if-present run test:unit:rsc",
"test:unit:all": "pnpm run test:unit && pnpm run test:unit:rsc",
"test:e2e": "pnpm --filter @rangojs/router test && pnpm --filter vite-rsc-demo test:full && pnpm --filter cloudflare-basic test:e2e && pnpm --filter cloudflare-basic-nonce test:e2e && pnpm --filter cloudflare-multi-router test:e2e && pnpm --filter react-experimental test:e2e && pnpm --filter no-typescript test:e2e && pnpm --filter rangojs-vercel-basic test:e2e",
"test:e2e": "pnpm --filter @rangojs/router test && pnpm --filter @rangojs/router test:preload && pnpm --filter vite-rsc-demo test:full && pnpm --filter cloudflare-basic test:e2e && pnpm --filter cloudflare-basic test:e2e:preload && pnpm --filter cloudflare-basic-nonce test:e2e && pnpm --filter cloudflare-multi-router test:e2e && pnpm --filter react-experimental test:e2e && pnpm --filter no-typescript test:e2e && pnpm --filter rangojs-vercel-basic test:e2e",
"test:hmr-local": "pnpm --filter @rangojs/router build && pnpm --filter @rangojs/router run test:hmr-local && pnpm --filter cloudflare-basic run test:e2e:hmr-local",
"build-router": "pnpm --filter @rangojs/router build",
"prepare": "lefthook install"
Expand Down
2 changes: 1 addition & 1 deletion packages/rangojs-router/docs/design/ppr-shell-resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ render and every upstream read it needs.
React 19.2 shipped Partial Pre-rendering in **stable**: `prerender` from
`react-dom/static` returns `{ prelude, postponed }` when aborted mid-suspense,
and `resume` from `react-dom/server` continues that exact render, emitting only
the postponed holes. The repo's pinned react/react-dom/vendored RSD (19.2.6)
the postponed holes. The repo's pinned react/react-dom/vendored RSD (19.2.8)
all carry the full surface — verified by runtime probe, not just export lists.

So: cache the rendered HTML **prelude** plus the postponed state, serve those
Expand Down
61 changes: 61 additions & 0 deletions packages/rangojs-router/e2e/client-package-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import { useFixture } from "./fixture";
import { expectNoPageError, testId, waitForHydration } from "./helper";

test.describe("client package resolution", () => {
const f = useFixture({ root: "./e2e/test-app", mode: "dev" });

test("preserves the deep context in server HTML", async ({ request }) => {
const response = await request.get(f.url("/client-package-resolution"), {
headers: { Accept: "text/html,application/xhtml+xml" },
});
expect(response.status()).toBe(200);

const html = await response.text();
expect(html).toContain("deep-context-value");
expect(html).not.toContain("NOT_FOUND");
});

test("preserves the deep context after hydration", async ({ page }) => {
using _ = expectNoPageError(page);

await page.goto(f.url("/client-package-resolution"));
await waitForHydration(page);

await expect(testId(page, "deep-context-value")).toHaveText(
"deep-context-value",
);
await expect(testId(page, "deep-context-value")).not.toHaveText(
"NOT_FOUND",
);
});
});

test.describe("client package resolution (production)", () => {
const f = useFixture({ root: "./e2e/test-app", mode: "build" });

test("preserves the deep context in server HTML", async ({ request }) => {
const response = await request.get(f.url("/client-package-resolution"), {
headers: { Accept: "text/html,application/xhtml+xml" },
});
expect(response.status()).toBe(200);

const html = await response.text();
expect(html).toContain("deep-context-value");
expect(html).not.toContain("NOT_FOUND");
});

test("preserves the deep context after hydration", async ({ page }) => {
using _ = expectNoPageError(page);

await page.goto(f.url("/client-package-resolution"));
await waitForHydration(page);

await expect(testId(page, "deep-context-value")).toHaveText(
"deep-context-value",
);
await expect(testId(page, "deep-context-value")).not.toHaveText(
"NOT_FOUND",
);
});
});
17 changes: 17 additions & 0 deletions packages/rangojs-router/e2e/head-script-preload-warmup.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { test as setup, expect } from "@playwright/test";

setup("warmup preload dev server", async ({ page, baseURL }) => {
setup.setTimeout(60_000);

await expect(async () => {
const first = await page.goto(new URL("/", baseURL).href);
expect(first?.ok()).toBe(true);

const second = await page.reload();
expect(second?.ok()).toBe(true);
await page.waitForFunction(
() => document.documentElement.hasAttribute("data-hydrated"),
{ timeout: 10_000 },
);
}).toPass({ timeout: 45_000, intervals: [2_000, 3_000, 5_000] });
});
88 changes: 88 additions & 0 deletions packages/rangojs-router/e2e/head-script-preload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import test, { expect, type Page } from "@playwright/test";
import {
fetchDocument,
modulepreloadHrefs,
scriptAndLinkTags,
} from "@shared/e2e";
import { blockPrefetch } from "@rangojs/router/testing/e2e";
import { useFixture } from "./fixture";
import { expectNoPageError, waitForHydration } from "./helper";

function assertPreloadDocument(
html: string,
requireClientReferenceHint: boolean,
): void {
const headEnd = html.indexOf("</head>");
expect(headEnd).toBeGreaterThan(0);
const head = html.slice(0, headEnd);
const externalModuleScripts = scriptAndLinkTags(head).filter(
(tag) =>
tag.startsWith("<script") &&
tag.includes('type="module"') &&
tag.includes('src="'),
);
expect(externalModuleScripts).toEqual([]);

const bootstrap = html.match(
/<script\b(?=[^>]*\bid="_R_")[^>]*>([\s\S]*?)<\/script>/,
);
expect(
bootstrap,
"the inline fizz bootstrap script is present",
).not.toBeNull();
const tagEnd = bootstrap![0].indexOf(">");
const tag = bootstrap![0].slice(0, tagEnd + 1);
expect(tag).not.toContain('src="');
const entryImport = bootstrap![1]!.match(
/\bimport\(\s*(["'])([^"']+)\1\s*\)/,
);
expect(
entryImport,
"the inline bootstrap imports the browser entry",
).not.toBeNull();

if (requireClientReferenceHint) {
const browserEntryUrl = entryImport![2]!;
const clientReferenceHrefs = modulepreloadHrefs(html).filter(
(href) => href !== browserEntryUrl,
);
expect(clientReferenceHrefs.length).toBeGreaterThan(0);
}
}

async function expectCleanHydration(page: Page, url: string): Promise<void> {
using _ = expectNoPageError(page);
await blockPrefetch(page);
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});

await page.goto(url);
await waitForHydration(page);
expect(consoleErrors).toEqual([]);
}

test.describe("head-script-preload", () => {
const f = useFixture({ root: "./e2e/test-app", mode: "dev" });

test("uses an inline bootstrap without executing external head scripts", async () => {
assertPreloadDocument(await fetchDocument(f.url("/")), false);
});

test("hydrates without browser errors", async ({ page }) => {
await expectCleanHydration(page, f.url("/"));
});
});

test.describe("head-script-preload (production)", () => {
const f = useFixture({ root: "./e2e/test-app", mode: "build" });

test("keeps client-reference hints with an inline bootstrap", async () => {
assertPreloadDocument(await fetchDocument(f.url("/")), true);
});

test("hydrates without browser errors", async ({ page }) => {
await expectCleanHydration(page, f.url("/"));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";

export interface DeepContextServerWrapperProps {
children: ReactNode;
value: string;
}

export function DeepContextServerWrapper(
props: DeepContextServerWrapperProps,
): ReactNode;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createElement } from "react";
import { DeepContextProvider } from "./internal/context.js";

export function DeepContextServerWrapper({ children, value }) {
return createElement(DeepContextProvider, { value }, children);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";

export interface DeepContextProviderProps {
children: ReactNode;
value: string;
}

export function DeepContextProvider(props: DeepContextProviderProps): ReactNode;
export function useDeepContext(): string | undefined;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { createContext, createElement, useContext } from "react";

const DeepContext = createContext(undefined);

export function DeepContextProvider({ children, value }) {
return createElement(DeepContext.Provider, { value }, children);
}

export function useDeepContext() {
return useContext(DeepContext);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "rango-e2e-deep-context-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
},
"./context": {
"types": "./internal/context.d.ts",
"default": "./internal/context.js"
}
},
"peerDependencies": {
"react": ">=19"
}
}
1 change: 1 addition & 0 deletions packages/rangojs-router/e2e/test-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@rangojs/router": "workspace:*",
"fake-context-lib": "file:__test_packages__/fake-context-lib",
"rango-e2e-deep-context-lib": "file:__test_packages__/rango-e2e-deep-context-lib",
"react": "catalog:",
"react-dom": "catalog:"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import { useDeepContext } from "rango-e2e-deep-context-lib/context";
import type { ReactNode } from "react";

export function ClientPackageResolutionConsumer(): ReactNode {
return (
<p data-testid="deep-context-value">{useDeepContext() ?? "NOT_FOUND"}</p>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Outlet } from "@rangojs/router/client";
import { DeepContextServerWrapper } from "rango-e2e-deep-context-lib";
import type { ReactNode } from "react";

export function ClientPackageResolutionLayout(): ReactNode {
return (
<DeepContextServerWrapper value="deep-context-value">
<Outlet />
</DeepContextServerWrapper>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const NamedRoutes = {
"clientIntercept.detail": "/client-urls-intercept/detail/:itemId",
"clientIntercept.index": "/client-urls-intercept",
"clientIntercept.item": "/client-urls-intercept/items/:itemId",
"clientPackageResolution.clientPackageResolutionIndex": "/client-package-resolution",
"clientTransition.item": "/client-urls-transition/items/:itemId",
"clientTransition.plain": "/client-urls-transition/plain/:itemId",
"colocatedFr.index": "/colocated-fr",
Expand Down
5 changes: 5 additions & 0 deletions packages/rangojs-router/e2e/test-app/src/urls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { alsScopePatterns } from "./urls/als-scope.js";
import { streamModePatterns } from "./urls/stream-mode.js";
import { devDebugPatterns, devInfoHandler } from "./urls/dev-routes.js";
import { contextDedupPatterns } from "./urls/context-dedup.js";
import { clientPackageResolutionPatterns } from "./urls/client-package-resolution.js";
import { parallelMetaPatterns } from "./urls/parallel-meta.js";
import { parallelMetaStalePatterns } from "./urls/parallel-meta-stale.js";
import { renderedBarrierPatterns } from "./urls/rendered-barrier.js";
Expand Down Expand Up @@ -1599,6 +1600,10 @@ export const urlpatterns = urls(
name: "contextDedup",
}),

include("/client-package-resolution", clientPackageResolutionPatterns, {
name: "clientPackageResolution",
}),

// @meta parallel slot pattern (handles from parallel slots)
include("/parallel-meta", parallelMetaPatterns, {
name: "parallelMeta",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { urls } from "@rangojs/router";
import { ClientPackageResolutionConsumer } from "../components/ClientPackageResolutionConsumer.js";
import { ClientPackageResolutionLayout } from "../components/layouts/ClientPackageResolutionLayout.js";

export const clientPackageResolutionPatterns = urls(({ path, layout }) => [
layout(ClientPackageResolutionLayout, () => [
path("/", () => <ClientPackageResolutionConsumer />, {
name: "clientPackageResolutionIndex",
}),
]),
]);
Loading
Loading