@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.d98a8e9d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -25
- package/dist/bin/rango.js +147 -57
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +2154 -861
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +57 -11
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +45 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +46 -4
- package/skills/layout/SKILL.md +28 -7
- package/skills/links/SKILL.md +247 -17
- package/skills/loader/SKILL.md +219 -9
- package/skills/middleware/SKILL.md +47 -12
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +71 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +243 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +122 -47
- package/skills/route/SKILL.md +57 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/testing/SKILL.md +128 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +121 -0
- package/skills/testing/e2e-parity.md +124 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +127 -0
- package/skills/testing/loader.md +108 -0
- package/skills/testing/middleware.md +97 -0
- package/skills/testing/render-handler.md +102 -0
- package/skills/testing/response-routes.md +94 -0
- package/skills/testing/reverse-and-types.md +83 -0
- package/skills/testing/server-actions.md +89 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/typesafety/SKILL.md +319 -27
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +86 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +84 -11
- package/src/browser/navigation-client.ts +104 -68
- package/src/browser/navigation-store.ts +32 -9
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +64 -26
- package/src/browser/prefetch/cache.ts +183 -44
- package/src/browser/prefetch/fetch.ts +228 -37
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +72 -31
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +20 -8
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +22 -2
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +32 -1
- package/src/browser/rsc-router.tsx +69 -22
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +23 -30
- package/src/browser/types.ts +21 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +95 -25
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +54 -13
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +96 -205
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -4
- package/src/handle.ts +32 -14
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +140 -21
- package/src/index.rsc.ts +10 -6
- package/src/index.ts +54 -17
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +25 -7
- package/src/loader.ts +16 -9
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/prerender.ts +27 -6
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -36
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +384 -257
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +100 -28
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +26 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/find-match.ts +54 -6
- package/src/router/handler-context.ts +21 -38
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +41 -22
- package/src/router/loader-resolution.ts +82 -36
- package/src/router/manifest.ts +41 -19
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +63 -20
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +53 -32
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +15 -26
- package/src/router/middleware.ts +99 -84
- package/src/router/pattern-matching.ts +116 -19
- package/src/router/prerender-match.ts +1 -1
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +4 -28
- package/src/router/revalidation.ts +58 -2
- package/src/router/router-interfaces.ts +45 -28
- package/src/router/router-options.ts +40 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +27 -6
- package/src/router/segment-resolution/revalidation.ts +147 -106
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router/trie-matching.ts +40 -16
- package/src/router/types.ts +8 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +52 -30
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +28 -69
- package/src/rsc/helpers.ts +91 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +57 -61
- package/src/rsc/rsc-rendering.ts +35 -51
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +17 -37
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +8 -2
- package/src/runtime-env.ts +18 -0
- package/src/search-params.ts +4 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +132 -116
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +175 -53
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +67 -51
- package/src/ssr/index.tsx +5 -1
- package/src/static-handler.ts +25 -3
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +581 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +326 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +110 -0
- package/src/testing/flight-normalize.ts +38 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +51 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +106 -0
- package/src/testing/internal/context.ts +304 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +42 -0
- package/src/testing/render-handler.ts +323 -0
- package/src/testing/render-route.tsx +590 -0
- package/src/testing/run-loader.ts +363 -0
- package/src/testing/run-middleware.ts +205 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +285 -0
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +68 -50
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +11 -9
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +35 -2
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +1 -5
- package/src/urls/path-helper-types.ts +41 -7
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +22 -29
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +106 -75
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +67 -26
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +8 -7
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -5
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +54 -30
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +496 -486
- package/src/vite/plugins/performance-tracks.ts +29 -25
- package/src/vite/plugins/use-cache-transform.ts +65 -50
- package/src/vite/plugins/version-injector.ts +39 -23
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +116 -29
- package/src/vite/router-discovery.ts +750 -100
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +8 -59
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +21 -6
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/// <reference path="./flight-runtime.d.ts" />
|
|
2
|
+
/**
|
|
3
|
+
* renderServerTree — REAL Flight serialize -> deserialize round-trip for unit
|
|
4
|
+
* tests, returning an INSPECTABLE React element tree (not just the wire string).
|
|
5
|
+
*
|
|
6
|
+
* Where it sits:
|
|
7
|
+
* - `renderRoute` (testing/dom): a synthetic CLIENT tree, no Flight at all.
|
|
8
|
+
* - `renderToFlightString` (testing/flight): the real Flight WIRE STRING, for
|
|
9
|
+
* `toMatchFlight` substring/snapshot assertions.
|
|
10
|
+
* - `renderServerTree` (here): serializes the real Flight, then deserializes it
|
|
11
|
+
* back to a React element tree you can traverse — so you can assert TYPED prop
|
|
12
|
+
* fidelity across the server/client boundary (a `Date` comes back a `Date`,
|
|
13
|
+
* not the opaque `$D...` wire encoding) and detect whether a `"use client"`
|
|
14
|
+
* component actually crossed the boundary (an `I` row) or was inlined.
|
|
15
|
+
*
|
|
16
|
+
* Scope (deliberate): serialize + deserialize ONLY. There is NO hydration and
|
|
17
|
+
* NO interaction — the deserialized client boundaries are inert placeholders
|
|
18
|
+
* carrying their props. Real interaction/hydration-mismatch testing stays at the
|
|
19
|
+
* e2e tier; in-process happy-dom hydration re-tests React more than your app and
|
|
20
|
+
* misses the only hydration bug worth a dedicated test (server/client divergence
|
|
21
|
+
* needs a real browser).
|
|
22
|
+
*
|
|
23
|
+
* Runs under the `react-server` export condition, in the SAME worker as the
|
|
24
|
+
* serializer (the client deserializer's react/react-dom imports are inert here
|
|
25
|
+
* because deserialize-only never renders). Use it from the rsc Vitest project
|
|
26
|
+
* (vitest.rsc.config.ts); name files `*.rsc-test.{ts,tsx}`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// MUST be first: defines the webpack-style globals the vendored client reads at
|
|
30
|
+
// module-eval time, before that client module is imported below.
|
|
31
|
+
import "./internal/flight-client-globals.js";
|
|
32
|
+
import { createFromReadableStream } from "@vitejs/plugin-rsc/react/browser";
|
|
33
|
+
import { setRequireModule } from "@vitejs/plugin-rsc/core/browser";
|
|
34
|
+
import * as RSDServer from "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge";
|
|
35
|
+
import type { ReactNode } from "react";
|
|
36
|
+
import { assertNoLegacyUrlOption, serializeToFlightString } from "./flight.js";
|
|
37
|
+
import type { RenderToFlightStringOptions } from "./flight.js";
|
|
38
|
+
|
|
39
|
+
/** Options for {@link renderServerTree}. */
|
|
40
|
+
export interface RenderServerTreeOptions extends RenderToFlightStringOptions {
|
|
41
|
+
/**
|
|
42
|
+
* The `"use client"` components reachable from the server tree, keyed by the
|
|
43
|
+
* name you want each boundary to have — usually the components you already
|
|
44
|
+
* import to render them: `{ clientComponents: { Counter, PriceTag } }`.
|
|
45
|
+
*
|
|
46
|
+
* The rsc Vitest project does NOT apply the `"use client"` transform, so a
|
|
47
|
+
* plainly-imported island is just a function the serializer would render
|
|
48
|
+
* server-side (and likely throw on a hook). Listing them here registers each as
|
|
49
|
+
* a client reference (in place) so it serializes as a real boundary (`I` row).
|
|
50
|
+
* This depends on NO filename convention — `"use client"` is marked by the
|
|
51
|
+
* directive, not the name, and you already import these to render them.
|
|
52
|
+
*
|
|
53
|
+
* Omit it for pure server-only trees. Components already registered as client
|
|
54
|
+
* references (e.g. by a transform) are left untouched. Registration is in place
|
|
55
|
+
* and per-worker first-wins: a component keeps the first name it is registered
|
|
56
|
+
* under for the rest of the test file.
|
|
57
|
+
*/
|
|
58
|
+
clientComponents?: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Result of {@link renderServerTree}. */
|
|
62
|
+
export interface RenderServerTreeResult {
|
|
63
|
+
/** The raw Flight wire string (so `toMatchFlight` assertions still apply). */
|
|
64
|
+
flight: string;
|
|
65
|
+
/**
|
|
66
|
+
* The deserialized React element tree. Server elements are plain React
|
|
67
|
+
* elements; each client boundary is an inert placeholder element whose `props`
|
|
68
|
+
* are the real, deserialized JS values that crossed the boundary. Use
|
|
69
|
+
* {@link findClientBoundaries} to locate them.
|
|
70
|
+
*/
|
|
71
|
+
tree: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A client boundary located in a deserialized tree. */
|
|
75
|
+
export interface ClientBoundary {
|
|
76
|
+
/** The id the boundary was registered under (the `clientComponents` key). */
|
|
77
|
+
id: string;
|
|
78
|
+
/** The boundary name (the `clientComponents` key). */
|
|
79
|
+
name: string;
|
|
80
|
+
/**
|
|
81
|
+
* The props that crossed the boundary, as real deserialized JS values
|
|
82
|
+
* (EXCLUDES `children` — read it off {@link ClientBoundary.children}, mirroring
|
|
83
|
+
* {@link FoundElement}).
|
|
84
|
+
*/
|
|
85
|
+
props: Record<string, unknown>;
|
|
86
|
+
/** The boundary's `props.children` (what was nested inside the island). */
|
|
87
|
+
children: unknown;
|
|
88
|
+
/** The raw deserialized element (for advanced assertions). */
|
|
89
|
+
element: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A selector for {@link findClientBoundaries}. Pass a string to match by export
|
|
94
|
+
* name (the back-compatible form), or this object to also filter by props /
|
|
95
|
+
* test id / an arbitrary predicate. All provided criteria are AND-ed.
|
|
96
|
+
*
|
|
97
|
+
* Only CLIENT boundaries are matched — a `data-testid` on a `"use client"`
|
|
98
|
+
* island is a prop that crossed the boundary (so `testId` finds it), but a
|
|
99
|
+
* `data-testid` on a plain server host element is NOT a boundary and is not
|
|
100
|
+
* matched here.
|
|
101
|
+
*/
|
|
102
|
+
export interface BoundarySelector {
|
|
103
|
+
/** Match the boundary's export name (same as passing a bare string). */
|
|
104
|
+
name?: string;
|
|
105
|
+
/** Match `props["data-testid"]` exactly (sugar over `props: { "data-testid": ... }`). */
|
|
106
|
+
testId?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Subset match: every listed prop must DEEP-EQUAL the boundary's prop of the
|
|
109
|
+
* same key (Date/Map/Set/array/nested-object aware). Props not listed are
|
|
110
|
+
* ignored, so `{ amount: 12.5 }` matches a boundary that also has other props.
|
|
111
|
+
*/
|
|
112
|
+
props?: Record<string, unknown>;
|
|
113
|
+
/** Arbitrary predicate, AND-ed with the criteria above. */
|
|
114
|
+
where?: (boundary: ClientBoundary) => boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Structural equality for boundary-prop matching. Handles the value kinds that
|
|
119
|
+
* survive a Flight round-trip (primitives, Date, Map, Set, Array, plain object);
|
|
120
|
+
* falls back to reference identity for anything exotic.
|
|
121
|
+
*
|
|
122
|
+
* `seen` guards against cyclic input: a deserialized tree can carry a cycle (an
|
|
123
|
+
* element whose props reference an ancestor), and without the guard the
|
|
124
|
+
* recursion blows the stack with a RangeError. When a pair `(a, b)` is already
|
|
125
|
+
* on the active comparison path we treat it as equal — the same
|
|
126
|
+
* reference-identity fallback the docs promise — so a cycle resolves instead of
|
|
127
|
+
* recursing forever.
|
|
128
|
+
*/
|
|
129
|
+
function deepEqual(
|
|
130
|
+
a: unknown,
|
|
131
|
+
b: unknown,
|
|
132
|
+
seen: WeakMap<object, WeakSet<object>> = new WeakMap(),
|
|
133
|
+
): boolean {
|
|
134
|
+
if (Object.is(a, b)) return true;
|
|
135
|
+
if (a instanceof Date && b instanceof Date)
|
|
136
|
+
return a.getTime() === b.getTime();
|
|
137
|
+
// Cycle guard: only objects can recurse, so key on object identity. If we are
|
|
138
|
+
// already comparing this exact pair higher in the stack, stop and treat it as
|
|
139
|
+
// equal (reference-identity fallback).
|
|
140
|
+
if (
|
|
141
|
+
a !== null &&
|
|
142
|
+
b !== null &&
|
|
143
|
+
typeof a === "object" &&
|
|
144
|
+
typeof b === "object"
|
|
145
|
+
) {
|
|
146
|
+
let partners = seen.get(a as object);
|
|
147
|
+
if (partners?.has(b as object)) return true;
|
|
148
|
+
if (!partners) {
|
|
149
|
+
partners = new WeakSet();
|
|
150
|
+
seen.set(a as object, partners);
|
|
151
|
+
}
|
|
152
|
+
partners.add(b as object);
|
|
153
|
+
}
|
|
154
|
+
if (a instanceof Map && b instanceof Map) {
|
|
155
|
+
if (a.size !== b.size) return false;
|
|
156
|
+
for (const [key, value] of a) {
|
|
157
|
+
if (!b.has(key) || !deepEqual(value, b.get(key), seen)) return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (a instanceof Set && b instanceof Set) {
|
|
162
|
+
if (a.size !== b.size) return false;
|
|
163
|
+
// Deep-equal each member (not shallow has()), so Sets of equal-but-distinct
|
|
164
|
+
// objects/Dates that survived deserialization still match.
|
|
165
|
+
const bValues = [...b];
|
|
166
|
+
for (const value of a) {
|
|
167
|
+
if (!bValues.some((other) => deepEqual(value, other, seen))) return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
172
|
+
return (
|
|
173
|
+
a.length === b.length &&
|
|
174
|
+
a.every((value, i) => deepEqual(value, b[i], seen))
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
a !== null &&
|
|
179
|
+
b !== null &&
|
|
180
|
+
typeof a === "object" &&
|
|
181
|
+
typeof b === "object" &&
|
|
182
|
+
!Array.isArray(a) &&
|
|
183
|
+
!Array.isArray(b)
|
|
184
|
+
) {
|
|
185
|
+
const aKeys = Object.keys(a as object);
|
|
186
|
+
const bKeys = Object.keys(b as object);
|
|
187
|
+
return (
|
|
188
|
+
aKeys.length === bKeys.length &&
|
|
189
|
+
aKeys.every((key) =>
|
|
190
|
+
deepEqual(
|
|
191
|
+
(a as Record<string, unknown>)[key],
|
|
192
|
+
(b as Record<string, unknown>)[key],
|
|
193
|
+
seen,
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Does a boundary satisfy every criterion of an object selector? */
|
|
202
|
+
function matchesSelector(
|
|
203
|
+
boundary: ClientBoundary,
|
|
204
|
+
selector: BoundarySelector,
|
|
205
|
+
): boolean {
|
|
206
|
+
if (selector.name !== undefined && boundary.name !== selector.name) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
selector.testId !== undefined &&
|
|
211
|
+
boundary.props["data-testid"] !== selector.testId
|
|
212
|
+
) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (selector.props !== undefined) {
|
|
216
|
+
for (const [key, value] of Object.entries(selector.props)) {
|
|
217
|
+
if (!deepEqual(boundary.props[key], value)) return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (selector.where !== undefined && !selector.where(boundary)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const CLIENT_REFERENCE = Symbol.for("react.client.reference");
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Tag a value as a client reference in place, unless it already is one. Accepts
|
|
230
|
+
* both functions and component OBJECTS — `memo(...)` / `forwardRef(...)` exports
|
|
231
|
+
* are objects at runtime, so a function-only check would skip them and the
|
|
232
|
+
* serializer would inline them server-side instead of emitting an `I` row. ESM
|
|
233
|
+
* live-binding identity means the server tree's own import of the same value then
|
|
234
|
+
* sees the reference, so it serializes as a boundary.
|
|
235
|
+
*/
|
|
236
|
+
function registerOne(value: unknown, id: string, exportName: string): void {
|
|
237
|
+
if (value === null) return;
|
|
238
|
+
const kind = typeof value;
|
|
239
|
+
if (kind !== "function" && kind !== "object") return;
|
|
240
|
+
const ref = value as { $$typeof?: symbol; $$id?: string };
|
|
241
|
+
if (ref.$$typeof === CLIENT_REFERENCE || ref.$$id) return;
|
|
242
|
+
RSDServer.registerClientReference(value, id, exportName);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Register `{ name: Component }` entries, keyed by name (id === name). */
|
|
246
|
+
export function registerClientComponents(
|
|
247
|
+
components: Record<string, unknown>,
|
|
248
|
+
): void {
|
|
249
|
+
for (const [name, value] of Object.entries(components)) {
|
|
250
|
+
registerOne(value, name, name);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A client manifest that resolves ANY registered client reference without
|
|
256
|
+
* needing to enumerate them. `$$id` is `${id}#${exportName}`; the serializer
|
|
257
|
+
* looks it up here. We omit `async`, so each `I` row is a 3-element row and the
|
|
258
|
+
* deserialized boundary's payload is a clean `resolved_module` whose value is
|
|
259
|
+
* `[id, [], name]` — synchronously readable by {@link findClientBoundaries}.
|
|
260
|
+
*/
|
|
261
|
+
export function makeClientManifest(): unknown {
|
|
262
|
+
return new Proxy(
|
|
263
|
+
{},
|
|
264
|
+
{
|
|
265
|
+
get(_target, key) {
|
|
266
|
+
if (typeof key !== "string") return undefined;
|
|
267
|
+
const hash = key.lastIndexOf("#");
|
|
268
|
+
const id = hash >= 0 ? key.slice(0, hash) : key;
|
|
269
|
+
const name = hash >= 0 ? key.slice(hash + 1) : "default";
|
|
270
|
+
return { id, chunks: [], name };
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Module-level "init once" flag. Safe ONLY because the Flight test project runs
|
|
277
|
+
// under `pool: "forks"` (see the shipped vitest.rsc.config.ts template): each
|
|
278
|
+
// test FILE gets its own process, so the flag is fresh per file. Under
|
|
279
|
+
// `pool: "threads"` (or any shared-worker pool) the loader would be installed
|
|
280
|
+
// once and silently reused across files — keep the Flight project on `forks`.
|
|
281
|
+
let loadInstalled = false;
|
|
282
|
+
/**
|
|
283
|
+
* Install the deserialize-side module loader. For the non-async manifest above,
|
|
284
|
+
* the deserializer never calls it (boundaries stay placeholders), so it throws a
|
|
285
|
+
* clear error if a code path ever does try to execute a client reference.
|
|
286
|
+
*/
|
|
287
|
+
function installDeserializeLoad(): void {
|
|
288
|
+
if (loadInstalled) return;
|
|
289
|
+
loadInstalled = true;
|
|
290
|
+
setRequireModule({
|
|
291
|
+
load: (id: string) => {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`renderServerTree does not execute client references (deserialize-only). ` +
|
|
294
|
+
`A client reference "${id}" was resolved — render/interaction is the e2e tier.`,
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function stringToStream(text: string): ReadableStream<Uint8Array> {
|
|
301
|
+
// TextEncoder replaces invalid UTF-16 (unmatched surrogates) with U+FFFD. Flight
|
|
302
|
+
// strings come from the serializer, so this is not a concern in practice; a
|
|
303
|
+
// mangled input would surface downstream as a deserialize error, not here.
|
|
304
|
+
const bytes = new TextEncoder().encode(text);
|
|
305
|
+
return new ReadableStream({
|
|
306
|
+
start(controller) {
|
|
307
|
+
controller.enqueue(bytes);
|
|
308
|
+
controller.close();
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Serialize a server component to real Flight, then deserialize it back to an
|
|
315
|
+
* inspectable React element tree. See the module header for scope.
|
|
316
|
+
*
|
|
317
|
+
* Must run under the `react-server` export condition.
|
|
318
|
+
*/
|
|
319
|
+
export async function renderServerTree(
|
|
320
|
+
element: ReactNode,
|
|
321
|
+
opts: RenderServerTreeOptions = {},
|
|
322
|
+
): Promise<RenderServerTreeResult> {
|
|
323
|
+
assertNoLegacyUrlOption(opts, "renderServerTree");
|
|
324
|
+
if (opts.clientComponents) registerClientComponents(opts.clientComponents);
|
|
325
|
+
const flight = await serializeToFlightString(
|
|
326
|
+
element,
|
|
327
|
+
opts,
|
|
328
|
+
makeClientManifest(),
|
|
329
|
+
);
|
|
330
|
+
return { flight, tree: await deserializeFlight(flight) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Deserialize a Flight wire string back to an inspectable React element tree:
|
|
335
|
+
* `createFromReadableStream` (vendored client), then unwrap Rango's payload
|
|
336
|
+
* wrapper and resolve the top server chunk so the consumer gets their element,
|
|
337
|
+
* not a lazy. Reused by renderServerTree AND renderHandler. Client references
|
|
338
|
+
* stay as inert boundary markers ({@link findClientBoundaries} reads them).
|
|
339
|
+
*/
|
|
340
|
+
export async function deserializeFlight(flight: string): Promise<unknown> {
|
|
341
|
+
installDeserializeLoad();
|
|
342
|
+
const payload = await createFromReadableStream(stringToStream(flight));
|
|
343
|
+
return resolveServerLazy(unwrapPayload(payload));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The serializer wraps the element in Rango's payload shape
|
|
348
|
+
* (`{ metadata: { segments: [{ component }] } }`) to mirror the real wire
|
|
349
|
+
* format. Return the consumer's own element tree, not that wrapper.
|
|
350
|
+
*/
|
|
351
|
+
function unwrapPayload(payload: unknown): unknown {
|
|
352
|
+
const segment = (
|
|
353
|
+
payload as { metadata?: { segments?: Array<{ component?: unknown }> } }
|
|
354
|
+
)?.metadata?.segments?.[0];
|
|
355
|
+
return segment && "component" in segment ? segment.component : payload;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
interface FlightLazy {
|
|
359
|
+
_payload: { status: string; value: unknown };
|
|
360
|
+
_init: (payload: unknown) => unknown;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function asFlightLazy(node: unknown): FlightLazy | undefined {
|
|
364
|
+
const candidate = node as Partial<FlightLazy> | null;
|
|
365
|
+
if (
|
|
366
|
+
candidate &&
|
|
367
|
+
typeof candidate === "object" &&
|
|
368
|
+
typeof candidate._init === "function" &&
|
|
369
|
+
candidate._payload &&
|
|
370
|
+
typeof (candidate._payload as { status?: unknown }).status === "string"
|
|
371
|
+
) {
|
|
372
|
+
return candidate as FlightLazy;
|
|
373
|
+
}
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* An async server component serializes as a deferred chunk that deserializes to
|
|
379
|
+
* a lazy (`status: "resolved_model"`). Initialize it to the materialized element
|
|
380
|
+
* (synchronous for a fully-drained stream; never calls a client `load`). Client
|
|
381
|
+
* references (`status: "resolved_module"`) are left untouched — they are the
|
|
382
|
+
* boundary markers {@link findClientBoundaries} reads.
|
|
383
|
+
*
|
|
384
|
+
* Why `"fulfilled"` is also accepted: the vendored client's readChunk (which is
|
|
385
|
+
* the lazy's `_init`) is destructive — the FIRST read transitions the shared
|
|
386
|
+
* chunk `resolved_model` -> `fulfilled` and stores the materialized value on
|
|
387
|
+
* `_payload.value` (see initializeModelChunk in the vendored client). A second
|
|
388
|
+
* encounter of the same lazy (textContent called twice, or a parent host
|
|
389
|
+
* element materializing the chunk before a child reads it within one walk) then
|
|
390
|
+
* sees `fulfilled`. Pre-fix this guard bailed on `fulfilled` and returned the
|
|
391
|
+
* lazy wrapper, so the subtree was silently skipped (textContent went ""; a
|
|
392
|
+
* findElements text selector missed). Initializing a `fulfilled` chunk is a
|
|
393
|
+
* no-op that returns `_payload.value`, so accepting it makes resolution
|
|
394
|
+
* idempotent.
|
|
395
|
+
*/
|
|
396
|
+
function resolveServerLazy(node: unknown): unknown {
|
|
397
|
+
let current = node;
|
|
398
|
+
for (let guard = 0; guard < 1000; guard++) {
|
|
399
|
+
const lazy = asFlightLazy(current);
|
|
400
|
+
const status = lazy?._payload.status;
|
|
401
|
+
if (!lazy || (status !== "resolved_model" && status !== "fulfilled")) {
|
|
402
|
+
return current;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
current = lazy._init(lazy._payload);
|
|
406
|
+
} catch {
|
|
407
|
+
return current;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return current;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
interface ClientBoundaryElement {
|
|
414
|
+
type: { _payload: { status: string; value: unknown[] } };
|
|
415
|
+
props?: Record<string, unknown>;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isClientBoundaryElement(node: unknown): node is ClientBoundaryElement {
|
|
419
|
+
const type = (node as { type?: unknown })?.type as
|
|
420
|
+
| { _payload?: { status?: string; value?: unknown } }
|
|
421
|
+
| undefined;
|
|
422
|
+
const payload = type?._payload;
|
|
423
|
+
return (
|
|
424
|
+
!!payload &&
|
|
425
|
+
payload.status === "resolved_module" &&
|
|
426
|
+
Array.isArray(payload.value)
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Walk a deserialized tree and return every client boundary, in document order,
|
|
432
|
+
* each with its id, export name, and typed props. The optional second arg
|
|
433
|
+
* filters the result:
|
|
434
|
+
* - a STRING matches by export name (`findClientBoundaries(tree, "PriceTag")`);
|
|
435
|
+
* - a {@link BoundarySelector} object filters by `name` / `testId` / `props`
|
|
436
|
+
* (subset deep-equal) / `where` predicate, AND-ed
|
|
437
|
+
* (`findClientBoundaries(tree, { testId: "price-tag" })`).
|
|
438
|
+
*
|
|
439
|
+
* Always returns an array (no throw on zero/many). For a single expected
|
|
440
|
+
* boundary, destructure the first: `const [tag] = findClientBoundaries(tree,
|
|
441
|
+
* "PriceTag")` — and assert on `.length` when the count matters (no match
|
|
442
|
+
* yields `[]`, so `tag` would be `undefined`).
|
|
443
|
+
*/
|
|
444
|
+
/**
|
|
445
|
+
* Walk a deserialized tree, calling `visit` on every materialized object node in
|
|
446
|
+
* document order (parent before children). Async-server-component chunks are
|
|
447
|
+
* materialized via resolveServerLazy so the walk descends into them; arrays are
|
|
448
|
+
* traversed but not themselves visited. Shared by findClientBoundaries and
|
|
449
|
+
* findElements.
|
|
450
|
+
*/
|
|
451
|
+
function walkNodes(tree: unknown, visit: (node: object) => void): void {
|
|
452
|
+
const seen = new Set<unknown>();
|
|
453
|
+
const recur = (raw: unknown): void => {
|
|
454
|
+
const node = resolveServerLazy(raw);
|
|
455
|
+
if (node == null || typeof node !== "object") return;
|
|
456
|
+
if (seen.has(node)) return;
|
|
457
|
+
seen.add(node);
|
|
458
|
+
if (Array.isArray(node)) {
|
|
459
|
+
for (const child of node) recur(child);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
visit(node);
|
|
463
|
+
// Recurse all own enumerable values: the tree is a deserialized payload
|
|
464
|
+
// (metadata -> segments -> component -> children/props), not just nested
|
|
465
|
+
// React props.
|
|
466
|
+
for (const value of Object.values(node as Record<string, unknown>)) {
|
|
467
|
+
recur(value);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
recur(tree);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function findClientBoundaries(
|
|
474
|
+
tree: unknown,
|
|
475
|
+
selector?: string | BoundarySelector,
|
|
476
|
+
): ClientBoundary[] {
|
|
477
|
+
const out: ClientBoundary[] = [];
|
|
478
|
+
walkNodes(tree, (node) => {
|
|
479
|
+
if (isClientBoundaryElement(node)) {
|
|
480
|
+
const value = node.type._payload.value;
|
|
481
|
+
const { children, ...rest } = (node.props ?? {}) as Record<
|
|
482
|
+
string,
|
|
483
|
+
unknown
|
|
484
|
+
>;
|
|
485
|
+
out.push({
|
|
486
|
+
id: String(value[0]),
|
|
487
|
+
name: String(value[2]),
|
|
488
|
+
props: rest,
|
|
489
|
+
children,
|
|
490
|
+
element: node,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
if (selector === undefined) return out;
|
|
495
|
+
if (typeof selector === "string") {
|
|
496
|
+
return out.filter((boundary) => boundary.name === selector);
|
|
497
|
+
}
|
|
498
|
+
return out.filter((boundary) => matchesSelector(boundary, selector));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** A server/host element located in a deserialized tree by {@link findElements}. */
|
|
502
|
+
export interface FoundElement {
|
|
503
|
+
/** The host tag name (`"article"`, `"h2"`). Always a host element. */
|
|
504
|
+
tag: string;
|
|
505
|
+
/** The element's props, as real deserialized JS values (excludes `children`). */
|
|
506
|
+
props: Record<string, unknown>;
|
|
507
|
+
/** The element's `props.children` (the rendered child tree), for convenience. */
|
|
508
|
+
children: unknown;
|
|
509
|
+
/** Concatenated text content of this element's subtree. */
|
|
510
|
+
text: string;
|
|
511
|
+
/** The raw deserialized element (for advanced assertions). */
|
|
512
|
+
element: unknown;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A selector for {@link findElements}. Pass a string to match a host tag name
|
|
517
|
+
* (`"h2"`), or this object for finer matches. All provided criteria are AND-ed.
|
|
518
|
+
*
|
|
519
|
+
* Mirrors {@link BoundarySelector} but keys on `tag` (the host tag) rather than
|
|
520
|
+
* `name` (a client component's export identity) — by design, since a host element
|
|
521
|
+
* has no component name. It also adds `text`, which a boundary selector lacks: a
|
|
522
|
+
* host element has rendered text, whereas a client boundary is an inert
|
|
523
|
+
* placeholder with no rendered children to match against.
|
|
524
|
+
*/
|
|
525
|
+
export interface ElementSelector {
|
|
526
|
+
/** Match the host tag name (`"article"`, `"h2"`). */
|
|
527
|
+
tag?: string;
|
|
528
|
+
/** Match `props["data-testid"]` exactly. */
|
|
529
|
+
testId?: string;
|
|
530
|
+
/** Subset deep-equal match on props (Date/Map/Set/array/nested aware). */
|
|
531
|
+
props?: Record<string, unknown>;
|
|
532
|
+
/** Match the element's text content (substring for a string, `.test()` for a RegExp). */
|
|
533
|
+
text?: string | RegExp;
|
|
534
|
+
/** Arbitrary predicate, AND-ed with the criteria above. */
|
|
535
|
+
where?: (element: FoundElement) => boolean;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// React 19 stamps elements with `react.transitional.element`; `react.element` is
|
|
539
|
+
// the React 18 symbol. Accept both so the check is robust across React majors.
|
|
540
|
+
// This `$$typeof` test is load-bearing: it distinguishes a real element from a
|
|
541
|
+
// plain payload object that merely has a string `type` field (e.g. an input's
|
|
542
|
+
// `props` object `{ type: "text" }`), which would otherwise look like a host element.
|
|
543
|
+
const REACT_ELEMENT = Symbol.for("react.element");
|
|
544
|
+
const REACT_TRANSITIONAL_ELEMENT = Symbol.for("react.transitional.element");
|
|
545
|
+
|
|
546
|
+
/** Is a node a React element (host or component), as opposed to a plain object? */
|
|
547
|
+
function isReactElement(
|
|
548
|
+
node: object,
|
|
549
|
+
): node is { type: unknown; props?: Record<string, unknown> } {
|
|
550
|
+
const tag = (node as { $$typeof?: symbol }).$$typeof;
|
|
551
|
+
return (
|
|
552
|
+
(tag === REACT_ELEMENT || tag === REACT_TRANSITIONAL_ELEMENT) &&
|
|
553
|
+
"type" in node
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Concatenate the text content of a deserialized node's subtree — every string
|
|
559
|
+
* and number leaf, in document order, space-free (`<h2>Wine {2}</h2>` ->
|
|
560
|
+
* `"Wine 2"` only if the source had the space). Use it to assert rendered text
|
|
561
|
+
* without reaching for `JSON.stringify(tree).toContain(...)`.
|
|
562
|
+
*/
|
|
563
|
+
export function textContent(node: unknown): string {
|
|
564
|
+
let out = "";
|
|
565
|
+
const recur = (raw: unknown): void => {
|
|
566
|
+
const value = resolveServerLazy(raw);
|
|
567
|
+
if (value == null || typeof value === "boolean") return;
|
|
568
|
+
if (typeof value === "string") {
|
|
569
|
+
out += value;
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
573
|
+
// React renders both numbers and bigints as text; mirror that here so a
|
|
574
|
+
// {2n} leaf is counted like a {2} leaf.
|
|
575
|
+
out += String(value);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (Array.isArray(value)) {
|
|
579
|
+
for (const child of value) recur(child);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (typeof value === "object") {
|
|
583
|
+
// A React element: descend into its children only (not props/type), so a
|
|
584
|
+
// string-valued prop like className is not counted as text.
|
|
585
|
+
if (isReactElement(value)) {
|
|
586
|
+
recur((value.props as { children?: unknown })?.children);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
recur(node);
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Walk a deserialized tree and return every SERVER/HOST element (the output a
|
|
596
|
+
* server component rendered), in document order. The optional second arg filters:
|
|
597
|
+
* - a STRING matches a host tag name (`findElements(tree, "h2")`);
|
|
598
|
+
* - an {@link ElementSelector} filters by `tag` / `testId` / `props` (subset
|
|
599
|
+
* deep-equal) / `text` (substring or RegExp) / `where`, AND-ed.
|
|
600
|
+
*
|
|
601
|
+
* Caveat: server COMPONENTS do not survive Flight as identities — they are
|
|
602
|
+
* executed during serialization, so only the host elements they produced remain.
|
|
603
|
+
* Match those host elements (by tag/props/text), not the component function. For
|
|
604
|
+
* CLIENT islands (which DO keep identity) use {@link findClientBoundaries}.
|
|
605
|
+
*
|
|
606
|
+
* Always returns an array (destructure the first for a single expected match).
|
|
607
|
+
*/
|
|
608
|
+
export function findElements(
|
|
609
|
+
tree: unknown,
|
|
610
|
+
selector?: string | ElementSelector,
|
|
611
|
+
): FoundElement[] {
|
|
612
|
+
const out: FoundElement[] = [];
|
|
613
|
+
walkNodes(tree, (node) => {
|
|
614
|
+
// Host elements only (typeof type === "string"). Excludes: client boundaries
|
|
615
|
+
// (type is a lazy module placeholder -> findClientBoundaries), fragments and
|
|
616
|
+
// other component elements (type is a Symbol/function), and plain payload
|
|
617
|
+
// objects (isReactElement guards against an object whose `type` is a string
|
|
618
|
+
// prop, like an input's `{ type: "text" }`).
|
|
619
|
+
if (!isReactElement(node) || typeof node.type !== "string") return;
|
|
620
|
+
const props = (node.props ?? {}) as Record<string, unknown>;
|
|
621
|
+
const { children, ...rest } = props;
|
|
622
|
+
out.push({
|
|
623
|
+
tag: node.type,
|
|
624
|
+
props: rest,
|
|
625
|
+
children,
|
|
626
|
+
text: textContent(node),
|
|
627
|
+
element: node,
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
if (selector === undefined) return out;
|
|
631
|
+
if (typeof selector === "string") {
|
|
632
|
+
return out.filter((element) => element.tag === selector);
|
|
633
|
+
}
|
|
634
|
+
return out.filter((element) => matchesElementSelector(element, selector));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Does a found element satisfy every criterion of an object selector? */
|
|
638
|
+
function matchesElementSelector(
|
|
639
|
+
element: FoundElement,
|
|
640
|
+
selector: ElementSelector,
|
|
641
|
+
): boolean {
|
|
642
|
+
if (selector.tag !== undefined && element.tag !== selector.tag) return false;
|
|
643
|
+
if (
|
|
644
|
+
selector.testId !== undefined &&
|
|
645
|
+
element.props["data-testid"] !== selector.testId
|
|
646
|
+
) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
if (selector.props !== undefined) {
|
|
650
|
+
for (const [key, value] of Object.entries(selector.props)) {
|
|
651
|
+
if (!deepEqual(element.props[key], value)) return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (selector.text !== undefined) {
|
|
655
|
+
const matched =
|
|
656
|
+
typeof selector.text === "string"
|
|
657
|
+
? element.text.includes(selector.text)
|
|
658
|
+
: selector.text.test(element.text);
|
|
659
|
+
if (!matched) return false;
|
|
660
|
+
}
|
|
661
|
+
if (selector.where !== undefined && !selector.where(element)) return false;
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Smoke check that the vendored client deserializer subpaths still resolve. The
|
|
667
|
+
* paths are private to plugin-rsc; a minor bump could relocate them. Call this in
|
|
668
|
+
* a test to fail loudly with a clear message instead of an opaque import error.
|
|
669
|
+
*/
|
|
670
|
+
export function assertFlightTreeRuntimeAvailable(): void {
|
|
671
|
+
if (
|
|
672
|
+
typeof createFromReadableStream !== "function" ||
|
|
673
|
+
typeof setRequireModule !== "function" ||
|
|
674
|
+
typeof RSDServer.registerClientReference !== "function"
|
|
675
|
+
) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
"renderServerTree runtime not available: a @vitejs/plugin-rsc client/server " +
|
|
678
|
+
"subpath did not export the expected function. A plugin-rsc upgrade may have " +
|
|
679
|
+
"moved react/browser, core/browser, or vendor/react-server-dom/server.edge.",
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
}
|