@bleedingdev/modern-js-runtime-extensions 3.8.3-ultramodern.6 → 3.9.0-ultramodern.10
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/dist/cjs/buildIdentity.js +73 -0
- package/dist/cjs/contextExtensions.js +74 -0
- package/dist/cjs/headRuntime.js +91 -0
- package/dist/cjs/helmetContext.js +54 -0
- package/dist/cjs/index.js +18 -4
- package/dist/cjs/localisedLoaderIdentity.js +51 -0
- package/dist/cjs/prefetchPolicy.js +52 -0
- package/dist/cjs/reactContext.js +45 -0
- package/dist/cjs/request-policy/browser.js +139 -0
- package/dist/cjs/request-policy/client.js +234 -0
- package/dist/cjs/request-policy/node.js +149 -0
- package/dist/cjs/request-policy/policyCore.js +266 -0
- package/dist/cjs/request-policy/requestContext.js +137 -0
- package/dist/cjs/request-policy/traceparent.js +56 -0
- package/dist/cjs/request-policy/transport.js +234 -0
- package/dist/cjs/request-policy/types.js +57 -0
- package/dist/cjs/routeHydration.js +127 -0
- package/dist/cjs/routerPrefetchPolicy.js +153 -0
- package/dist/cjs/routerProvider.js +105 -0
- package/dist/cjs/routerState.js +204 -0
- package/dist/cjs/routerStatePlugin.js +86 -0
- package/dist/cjs/routerStateTypes.js +18 -0
- package/dist/cjs/rsc-disabled/rscDisabledClientBrowserRuntime.js +69 -0
- package/dist/cjs/rsc-disabled/rscDisabledClientEdgeRuntime.js +61 -0
- package/dist/cjs/rsc-disabled/rscDisabledClientNodeRuntime.js +65 -0
- package/dist/cjs/rsc-disabled/rscDisabledRuntimeError.js +42 -0
- package/dist/cjs/rsc-disabled/rscDisabledServerEdgeRuntime.js +93 -0
- package/dist/cjs/rsc-disabled/rscDisabledServerNodeRuntime.js +101 -0
- package/dist/cjs/runtimeContextProjection.js +112 -0
- package/dist/cjs/server-config/bffRuntime.js +18 -0
- package/dist/cjs/server-config/index.js +18 -0
- package/dist/cjs/server-config/serverTelemetry.js +18 -0
- package/dist/cjs/shellStream.js +34 -2
- package/dist/cjs/workspaceEvents.js +159 -0
- package/dist/esm/buildIdentity.mjs +35 -0
- package/dist/esm/contextExtensions.mjs +33 -0
- package/dist/esm/headRuntime.mjs +43 -0
- package/dist/esm/helmetContext.mjs +13 -0
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/localisedLoaderIdentity.mjs +13 -0
- package/dist/esm/prefetchPolicy.mjs +11 -0
- package/dist/esm/reactContext.mjs +7 -0
- package/dist/esm/request-policy/browser.mjs +11 -0
- package/dist/esm/request-policy/client.mjs +195 -0
- package/dist/esm/request-policy/policyCore.mjs +171 -0
- package/dist/esm/request-policy/requestContext.mjs +75 -0
- package/dist/esm/request-policy/traceparent.mjs +18 -0
- package/dist/esm/request-policy/transport.mjs +196 -0
- package/dist/esm/request-policy/types.mjs +10 -0
- package/dist/esm/routeHydration.mjs +77 -0
- package/dist/esm/routerPrefetchPolicy.mjs +115 -0
- package/dist/esm/routerProvider.mjs +58 -0
- package/dist/esm/routerState.mjs +139 -0
- package/dist/esm/routerStatePlugin.mjs +48 -0
- package/dist/esm/routerStateTypes.mjs +0 -0
- package/dist/esm/rsc-disabled/rscDisabledClientBrowserRuntime.mjs +10 -0
- package/dist/esm/rsc-disabled/rscDisabledClientEdgeRuntime.mjs +8 -0
- package/dist/esm/rsc-disabled/rscDisabledClientNodeRuntime.mjs +9 -0
- package/dist/esm/rsc-disabled/rscDisabledRuntimeError.mjs +4 -0
- package/dist/esm/rsc-disabled/rscDisabledServerEdgeRuntime.mjs +16 -0
- package/dist/esm/rsc-disabled/rscDisabledServerNodeRuntime.mjs +18 -0
- package/dist/esm/runtimeContextProjection.mjs +74 -0
- package/dist/esm/server-config/bffRuntime.mjs +0 -0
- package/dist/esm/server-config/index.mjs +0 -0
- package/dist/esm/server-config/serverTelemetry.mjs +0 -0
- package/dist/esm/shellStream.mjs +34 -2
- package/dist/esm/workspaceEvents.mjs +66 -0
- package/dist/esm-node/buildIdentity.mjs +36 -0
- package/dist/esm-node/contextExtensions.mjs +34 -0
- package/dist/esm-node/headRuntime.mjs +44 -0
- package/dist/esm-node/helmetContext.mjs +14 -0
- package/dist/esm-node/index.mjs +2 -0
- package/dist/esm-node/localisedLoaderIdentity.mjs +14 -0
- package/dist/esm-node/prefetchPolicy.mjs +12 -0
- package/dist/esm-node/reactContext.mjs +8 -0
- package/dist/esm-node/request-policy/browser.mjs +12 -0
- package/dist/esm-node/request-policy/client.mjs +196 -0
- package/dist/esm-node/request-policy/node.mjs +22 -0
- package/dist/esm-node/request-policy/policyCore.mjs +172 -0
- package/dist/esm-node/request-policy/requestContext.mjs +76 -0
- package/dist/esm-node/request-policy/traceparent.mjs +19 -0
- package/dist/esm-node/request-policy/transport.mjs +197 -0
- package/dist/esm-node/request-policy/types.mjs +11 -0
- package/dist/esm-node/routeHydration.mjs +78 -0
- package/dist/esm-node/routerPrefetchPolicy.mjs +116 -0
- package/dist/esm-node/routerProvider.mjs +59 -0
- package/dist/esm-node/routerState.mjs +140 -0
- package/dist/esm-node/routerStatePlugin.mjs +49 -0
- package/dist/esm-node/routerStateTypes.mjs +1 -0
- package/dist/esm-node/rsc-disabled/rscDisabledClientBrowserRuntime.mjs +11 -0
- package/dist/esm-node/rsc-disabled/rscDisabledClientEdgeRuntime.mjs +9 -0
- package/dist/esm-node/rsc-disabled/rscDisabledClientNodeRuntime.mjs +10 -0
- package/dist/esm-node/rsc-disabled/rscDisabledRuntimeError.mjs +5 -0
- package/dist/esm-node/rsc-disabled/rscDisabledServerEdgeRuntime.mjs +17 -0
- package/dist/esm-node/rsc-disabled/rscDisabledServerNodeRuntime.mjs +19 -0
- package/dist/esm-node/runtimeContextProjection.mjs +75 -0
- package/dist/esm-node/server-config/bffRuntime.mjs +1 -0
- package/dist/esm-node/server-config/index.mjs +1 -0
- package/dist/esm-node/server-config/serverTelemetry.mjs +1 -0
- package/dist/esm-node/shellStream.mjs +34 -2
- package/dist/esm-node/workspaceEvents.mjs +67 -0
- package/dist/types/buildIdentity.d.ts +37 -0
- package/dist/types/contextExtensions.d.ts +34 -0
- package/dist/types/headRuntime.d.ts +12 -0
- package/dist/types/helmetContext.d.ts +10 -0
- package/dist/types/index.d.ts +6 -4
- package/dist/types/localisedLoaderIdentity.d.ts +10 -0
- package/dist/types/node.d.ts +1 -1
- package/dist/types/prefetchPolicy.d.ts +4 -0
- package/dist/types/reactContext.d.ts +6 -0
- package/dist/types/rendererHead.d.ts +1 -1
- package/dist/types/request-policy/browser.d.ts +10 -0
- package/dist/types/request-policy/client.d.ts +14 -0
- package/dist/types/request-policy/node.d.ts +10 -0
- package/dist/types/request-policy/policyCore.d.ts +107 -0
- package/dist/types/request-policy/requestContext.d.ts +22 -0
- package/dist/types/request-policy/traceparent.d.ts +10 -0
- package/dist/types/request-policy/transport.d.ts +12 -0
- package/dist/types/request-policy/types.d.ts +144 -0
- package/dist/types/routeHydration.d.ts +24 -0
- package/dist/types/routerPrefetchPolicy.d.ts +28 -0
- package/dist/types/routerProvider.d.ts +55 -0
- package/dist/types/routerState.d.ts +18 -0
- package/dist/types/routerStatePlugin.d.ts +19 -0
- package/dist/types/routerStateTypes.d.ts +40 -0
- package/dist/types/rsc-disabled/rscDisabledClientBrowserRuntime.d.ts +9 -0
- package/dist/types/rsc-disabled/rscDisabledClientEdgeRuntime.d.ts +7 -0
- package/dist/types/rsc-disabled/rscDisabledClientNodeRuntime.d.ts +8 -0
- package/dist/types/rsc-disabled/rscDisabledRuntimeError.d.ts +1 -0
- package/dist/types/rsc-disabled/rscDisabledServerEdgeRuntime.d.ts +15 -0
- package/dist/types/rsc-disabled/rscDisabledServerNodeRuntime.d.ts +17 -0
- package/dist/types/runtimeContextProjection.d.ts +30 -0
- package/dist/types/server-config/bffRuntime.d.ts +122 -0
- package/dist/types/server-config/index.d.ts +2 -0
- package/dist/types/server-config/serverTelemetry.d.ts +322 -0
- package/dist/types/workspaceEvents.d.ts +62 -0
- package/package.json +240 -15
- package/rslib.config.mts +17 -2
- package/rstest.config.mts +33 -2
- package/src/buildIdentity.ts +61 -0
- package/src/contextExtensions.ts +81 -0
- package/src/headRuntime.tsx +77 -0
- package/src/helmetContext.ts +31 -0
- package/src/index.ts +2 -0
- package/src/localisedLoaderIdentity.ts +30 -0
- package/src/prefetchPolicy.ts +33 -0
- package/src/reactContext.ts +16 -0
- package/src/request-policy/browser.ts +22 -0
- package/src/request-policy/client.ts +404 -0
- package/src/request-policy/node.ts +41 -0
- package/src/request-policy/policyCore.ts +424 -0
- package/src/request-policy/requestContext.ts +149 -0
- package/src/request-policy/traceparent.ts +43 -0
- package/src/request-policy/transport.ts +349 -0
- package/src/request-policy/types.ts +229 -0
- package/src/routeHydration.ts +208 -0
- package/src/routerPrefetchPolicy.ts +195 -0
- package/src/routerProvider.ts +160 -0
- package/src/routerState.ts +290 -0
- package/src/routerStatePlugin.ts +86 -0
- package/src/routerStateTypes.ts +46 -0
- package/src/rsc-disabled/rscDisabledClientBrowserRuntime.ts +10 -0
- package/src/rsc-disabled/rscDisabledClientEdgeRuntime.ts +8 -0
- package/src/rsc-disabled/rscDisabledClientNodeRuntime.ts +9 -0
- package/src/rsc-disabled/rscDisabledRuntimeError.ts +3 -0
- package/src/rsc-disabled/rscDisabledServerEdgeRuntime.ts +16 -0
- package/src/rsc-disabled/rscDisabledServerNodeRuntime.ts +18 -0
- package/src/runtimeContextProjection.ts +105 -0
- package/src/server-config/bffRuntime.ts +136 -0
- package/src/server-config/index.ts +2 -0
- package/src/server-config/serverTelemetry.ts +337 -0
- package/src/shellStream.ts +55 -25
- package/src/workspaceEvents.ts +324 -0
- package/tests/buildIdentity.test.ts +51 -0
- package/tests/contextExtensions.test.ts +27 -0
- package/tests/headRuntime.test.tsx +229 -0
- package/tests/rendererHead.test.ts +1 -113
- package/tests/request-policy/browser-policy.test.ts +108 -0
- package/tests/request-policy/client-isolation.test.ts +109 -0
- package/tests/request-policy/node-policy.test.ts +447 -0
- package/tests/request-policy/policyCore.test.ts +129 -0
- package/tests/request-policy/requestContext.test.ts +66 -0
- package/tests/request-policy/requestFactory.outbound-contract.test.ts +290 -0
- package/tests/request-policy/traceparent.test.ts +52 -0
- package/tests/request-policy/transport-retry.test.ts +367 -0
- package/tests/routeHydration.test.ts +180 -0
- package/tests/routerPrefetchPolicy.test.ts +102 -0
- package/tests/routerProvider.test.ts +29 -0
- package/tests/routerState.test.ts +78 -0
- package/tests/routerStatePlugin.test.ts +63 -0
- package/tests/runtimeContextProjection.test.ts +83 -0
- package/tests/safeFailure.test.ts +10 -28
- package/tests/shellStream.test.ts +37 -0
- package/tests/workspaceEvents.test.ts +221 -0
- package/dist/cjs/rscHtmlStream.js +0 -128
- package/dist/esm/rscHtmlStream.mjs +0 -86
- package/dist/esm-node/rscHtmlStream.mjs +0 -87
- package/dist/types/rscHtmlStream.d.ts +0 -4
- package/src/rscHtmlStream.ts +0 -142
- package/tests/rscHtmlStream.test.ts +0 -153
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRouteHydrationScriptTags,
|
|
3
|
+
getMatchedRouteChunks,
|
|
4
|
+
injectBeforeHydrationEntryScript,
|
|
5
|
+
orderHydrationScriptChunks,
|
|
6
|
+
replaceChunkJsPlaceholder,
|
|
7
|
+
} from '../src/routeHydration';
|
|
8
|
+
|
|
9
|
+
const manifest = {
|
|
10
|
+
routeAssets: {
|
|
11
|
+
layout: { assets: ['/shared.js', '/layout.css'] },
|
|
12
|
+
page: { assets: ['/page.js', '/shared.js'] },
|
|
13
|
+
empty: {},
|
|
14
|
+
missing: undefined,
|
|
15
|
+
'async-main': { assets: ['/shared.js', '/async-main.hash.js'] },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const tag = (src: string) => `<script src="${src}"></script>`;
|
|
20
|
+
|
|
21
|
+
describe('route hydration policy', () => {
|
|
22
|
+
it('maps matched assets in route order without deduplicating or filtering', () => {
|
|
23
|
+
const mapped = getMatchedRouteChunks(
|
|
24
|
+
manifest,
|
|
25
|
+
['layout', 'unknown', 'empty', 'missing', 'page'],
|
|
26
|
+
asset => ({ url: asset, marker: 'preserved' }),
|
|
27
|
+
);
|
|
28
|
+
expect(mapped).toEqual(
|
|
29
|
+
['/shared.js', '/layout.css', '/page.js', '/shared.js'].map(url => ({
|
|
30
|
+
url,
|
|
31
|
+
marker: 'preserved',
|
|
32
|
+
})),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns no chunks for an explicit missing manifest or empty matches', () => {
|
|
37
|
+
const toChunk = rstest.fn((url: string) => ({ url }));
|
|
38
|
+
expect(getMatchedRouteChunks(undefined, ['page'], toChunk)).toEqual([]);
|
|
39
|
+
expect(getMatchedRouteChunks({}, ['page'], toChunk)).toEqual([]);
|
|
40
|
+
expect(getMatchedRouteChunks(manifest, [], toChunk)).toEqual([]);
|
|
41
|
+
expect(toChunk).not.toHaveBeenCalled();
|
|
42
|
+
expect(createRouteHydrationScriptTags(undefined, ['page'], 'main')).toBe(
|
|
43
|
+
'',
|
|
44
|
+
);
|
|
45
|
+
expect(createRouteHydrationScriptTags({}, ['page'], 'main')).toBe('');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps dependencies, collected chunks and route chunks before async entry', () => {
|
|
49
|
+
const dependency = { url: '/dependency.js', marker: 'dependency' };
|
|
50
|
+
const collected = { url: '/collected.js', marker: 'collected' };
|
|
51
|
+
const route = { url: '/route.js', marker: 'route' };
|
|
52
|
+
const entry = { url: '/async-main.hash.js', marker: 'entry' };
|
|
53
|
+
const asyncEntryChunks = [entry, dependency];
|
|
54
|
+
const result = orderHydrationScriptChunks({
|
|
55
|
+
asyncEntryChunks,
|
|
56
|
+
collectedChunks: [collected, { ...dependency, marker: 'duplicate' }],
|
|
57
|
+
matchedRouteChunks: [route, { url: '' }, {}, { ...collected }],
|
|
58
|
+
entryName: 'main',
|
|
59
|
+
});
|
|
60
|
+
expect(result).toEqual([dependency, collected, route, entry]);
|
|
61
|
+
expect(result[0]).toBe(dependency);
|
|
62
|
+
expect(result[1]).toBe(collected);
|
|
63
|
+
expect(asyncEntryChunks).toEqual([entry, dependency]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('classifies async entries using filename while retaining the JS URL rule', () => {
|
|
67
|
+
const entry = { url: '/opaque.js', filename: 'nested/async-main-hash.js' };
|
|
68
|
+
const queried = { url: '/async-main.js?hash=1' };
|
|
69
|
+
const otherEntry = { url: '/async-other.js' };
|
|
70
|
+
const asset = { url: '/async-main.css' };
|
|
71
|
+
const route = { url: '/route.js' };
|
|
72
|
+
expect(
|
|
73
|
+
orderHydrationScriptChunks({
|
|
74
|
+
asyncEntryChunks: [entry, queried, otherEntry, asset],
|
|
75
|
+
collectedChunks: [],
|
|
76
|
+
matchedRouteChunks: [route],
|
|
77
|
+
entryName: 'main',
|
|
78
|
+
}),
|
|
79
|
+
).toEqual([queried, otherEntry, asset, route, entry]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('serializes unique JS route assets then async assets and matches exact src', () => {
|
|
83
|
+
expect(
|
|
84
|
+
createRouteHydrationScriptTags(manifest, ['layout', 'page'], 'main', {
|
|
85
|
+
template: `${tag('/page.js.map')}${tag('/shared.js')}`,
|
|
86
|
+
}),
|
|
87
|
+
).toBe(`${tag('/page.js')} ${tag('/async-main.hash.js')}`);
|
|
88
|
+
expect(createRouteHydrationScriptTags(manifest, [], 'main')).toBe(
|
|
89
|
+
`${tag('/shared.js')} ${tag('/async-main.hash.js')}`,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('escapes asset and nonce attributes and omits empty nonce', () => {
|
|
94
|
+
const unsafeManifest = {
|
|
95
|
+
routeAssets: {
|
|
96
|
+
page: { assets: ['/route" onload="alert(1)&x=<tag>.js'] },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
expect(
|
|
100
|
+
createRouteHydrationScriptTags(unsafeManifest, ['page'], 'main', {
|
|
101
|
+
nonce: 'nonce"&<value>',
|
|
102
|
+
}),
|
|
103
|
+
).toBe(
|
|
104
|
+
'<script src="/route" onload="alert(1)&x=<tag>.js" nonce="nonce"&<value>"></script>',
|
|
105
|
+
);
|
|
106
|
+
expect(
|
|
107
|
+
createRouteHydrationScriptTags(manifest, ['page'], 'other', {
|
|
108
|
+
nonce: '',
|
|
109
|
+
}),
|
|
110
|
+
).toBe(`${tag('/page.js')} ${tag('/shared.js')}`);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('prefers the main entry even when an async entry appears earlier', () => {
|
|
114
|
+
const asyncEntry = tag('/async-main.hash.js');
|
|
115
|
+
const entry =
|
|
116
|
+
"<script defer src='/nested/main-hash.js?build=1#hash'></script>";
|
|
117
|
+
const template = `${asyncEntry}${entry}`;
|
|
118
|
+
expect(injectBeforeHydrationEntryScript(template, 'ROUTE', 'main')).toBe(
|
|
119
|
+
`${asyncEntry}ROUTE${entry}`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('falls back to async entry and preserves templates with no entry', () => {
|
|
124
|
+
const asyncEntry = tag('/nested/async-main.js');
|
|
125
|
+
expect(injectBeforeHydrationEntryScript(asyncEntry, 'ROUTE', 'main')).toBe(
|
|
126
|
+
`ROUTE${asyncEntry}`,
|
|
127
|
+
);
|
|
128
|
+
expect(injectBeforeHydrationEntryScript(tag('/index.js'), 'ROUTE')).toBe(
|
|
129
|
+
`ROUTE${tag('/index.js')}`,
|
|
130
|
+
);
|
|
131
|
+
const other = tag('/other-main.js');
|
|
132
|
+
expect(injectBeforeHydrationEntryScript(other, 'ROUTE', 'main')).toBe(
|
|
133
|
+
other,
|
|
134
|
+
);
|
|
135
|
+
expect(injectBeforeHydrationEntryScript(asyncEntry, '', 'main')).toBe(
|
|
136
|
+
asyncEntry,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('moves placeholder scripts before hydration and replaces literal text safely', () => {
|
|
141
|
+
const placeholder = '<!-- chunks -->';
|
|
142
|
+
const scripts = '<script src="/$&-$`-$\'.js"></script>';
|
|
143
|
+
const entry = tag('/main.js');
|
|
144
|
+
expect(
|
|
145
|
+
replaceChunkJsPlaceholder(
|
|
146
|
+
`${entry}${placeholder}`,
|
|
147
|
+
scripts,
|
|
148
|
+
'main',
|
|
149
|
+
placeholder,
|
|
150
|
+
),
|
|
151
|
+
).toBe(`${scripts}${entry}`);
|
|
152
|
+
expect(
|
|
153
|
+
replaceChunkJsPlaceholder(
|
|
154
|
+
`before${placeholder}after`,
|
|
155
|
+
scripts,
|
|
156
|
+
'main',
|
|
157
|
+
placeholder,
|
|
158
|
+
),
|
|
159
|
+
).toBe(`before${scripts}after`);
|
|
160
|
+
expect(replaceChunkJsPlaceholder(entry, scripts, 'main', placeholder)).toBe(
|
|
161
|
+
entry,
|
|
162
|
+
);
|
|
163
|
+
expect(
|
|
164
|
+
replaceChunkJsPlaceholder(
|
|
165
|
+
`${entry}${placeholder}`,
|
|
166
|
+
'',
|
|
167
|
+
'main',
|
|
168
|
+
placeholder,
|
|
169
|
+
),
|
|
170
|
+
).toBe(entry);
|
|
171
|
+
expect(
|
|
172
|
+
replaceChunkJsPlaceholder(
|
|
173
|
+
`${placeholder}${placeholder}`,
|
|
174
|
+
'',
|
|
175
|
+
undefined,
|
|
176
|
+
placeholder,
|
|
177
|
+
),
|
|
178
|
+
).toBe(placeholder);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createRouterPrefetchPolicy } from '../src/routerPrefetchPolicy';
|
|
2
|
+
|
|
3
|
+
const settle = async () => {
|
|
4
|
+
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
afterEach(() => rstest.useRealTimers());
|
|
8
|
+
|
|
9
|
+
test('implements render defaults and cancels delayed intent before dispatch', async () => {
|
|
10
|
+
rstest.useFakeTimers();
|
|
11
|
+
const policy = createRouterPrefetchPolicy();
|
|
12
|
+
const notify = rstest.fn();
|
|
13
|
+
const render = policy.observe({ element: null, notify });
|
|
14
|
+
expect(notify).toHaveBeenLastCalledWith({ code: true, data: true });
|
|
15
|
+
render.dispose();
|
|
16
|
+
const intent = policy.observe({
|
|
17
|
+
element: null,
|
|
18
|
+
prefetch: 'none',
|
|
19
|
+
preload: 'intent',
|
|
20
|
+
notify,
|
|
21
|
+
});
|
|
22
|
+
intent.onIntent();
|
|
23
|
+
intent.onCancel();
|
|
24
|
+
await rstest.advanceTimersByTimeAsync(100);
|
|
25
|
+
expect(notify).toHaveBeenLastCalledWith({ code: false, data: false });
|
|
26
|
+
intent.onIntent();
|
|
27
|
+
await rstest.advanceTimersByTimeAsync(100);
|
|
28
|
+
expect(notify).toHaveBeenLastCalledWith({ code: true, data: false });
|
|
29
|
+
intent.dispose();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('limits concurrent loads, removes cancelled queued keys, and retries rejected loads', async () => {
|
|
33
|
+
const policy = createRouterPrefetchPolicy();
|
|
34
|
+
const context = {};
|
|
35
|
+
const loader = {};
|
|
36
|
+
const started: number[] = [];
|
|
37
|
+
const releases: (() => void)[] = [];
|
|
38
|
+
const schedule = (id: number) =>
|
|
39
|
+
policy.schedule({
|
|
40
|
+
runtimeContext: context,
|
|
41
|
+
chunkLoader: loader,
|
|
42
|
+
publicPath: '/',
|
|
43
|
+
key: String(id),
|
|
44
|
+
run: () => {
|
|
45
|
+
started.push(id);
|
|
46
|
+
return new Promise<void>(resolve => releases.push(resolve));
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const cancellations = Array.from({ length: 6 }, (_, id) => schedule(id));
|
|
50
|
+
await settle();
|
|
51
|
+
expect(started).toEqual([0, 1, 2, 3]);
|
|
52
|
+
cancellations[4]();
|
|
53
|
+
releases[0]();
|
|
54
|
+
await settle();
|
|
55
|
+
expect(started).toEqual([0, 1, 2, 3, 5]);
|
|
56
|
+
schedule(4);
|
|
57
|
+
releases[1]();
|
|
58
|
+
await settle();
|
|
59
|
+
expect(started).toEqual([0, 1, 2, 3, 5, 4]);
|
|
60
|
+
for (const release of releases) release();
|
|
61
|
+
await settle();
|
|
62
|
+
const fail = rstest.fn(() => Promise.reject(new Error('retry')));
|
|
63
|
+
const options = {
|
|
64
|
+
runtimeContext: context,
|
|
65
|
+
chunkLoader: loader,
|
|
66
|
+
publicPath: '/',
|
|
67
|
+
key: 'failed',
|
|
68
|
+
run: fail,
|
|
69
|
+
};
|
|
70
|
+
policy.schedule(options);
|
|
71
|
+
await settle();
|
|
72
|
+
policy.schedule(options);
|
|
73
|
+
await settle();
|
|
74
|
+
expect(fail).toHaveBeenCalledTimes(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('deduplicates only within matching context, loader, public path and live TTL', async () => {
|
|
78
|
+
let now = 0;
|
|
79
|
+
rstest.spyOn(performance, 'now').mockImplementation(() => now);
|
|
80
|
+
const policy = createRouterPrefetchPolicy();
|
|
81
|
+
const run = rstest.fn(() => Promise.resolve());
|
|
82
|
+
const options = {
|
|
83
|
+
runtimeContext: {},
|
|
84
|
+
chunkLoader: {},
|
|
85
|
+
publicPath: '/',
|
|
86
|
+
key: 'route',
|
|
87
|
+
run,
|
|
88
|
+
};
|
|
89
|
+
policy.schedule(options);
|
|
90
|
+
policy.schedule(options);
|
|
91
|
+
await settle();
|
|
92
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
93
|
+
policy.schedule({ ...options, runtimeContext: {} });
|
|
94
|
+
policy.schedule({ ...options, chunkLoader: {} });
|
|
95
|
+
policy.schedule({ ...options, publicPath: '/other/' });
|
|
96
|
+
await settle();
|
|
97
|
+
expect(run).toHaveBeenCalledTimes(4);
|
|
98
|
+
now = 30_001;
|
|
99
|
+
policy.schedule(options);
|
|
100
|
+
await settle();
|
|
101
|
+
expect(run).toHaveBeenCalledTimes(5);
|
|
102
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRouterPlugin } from '../src/routerProvider';
|
|
2
|
+
|
|
3
|
+
test('selects the injected app-local factory from runtime config and preserves native hook objects', () => {
|
|
4
|
+
const registryHooks = { afterCreate: { call() {} } };
|
|
5
|
+
const defaultFactory = rstest.fn(() => ({ setup: rstest.fn() }));
|
|
6
|
+
const setup = rstest.fn();
|
|
7
|
+
const localFactory = rstest.fn(() => ({ registryHooks, setup }));
|
|
8
|
+
const plugin = createRouterPlugin({
|
|
9
|
+
defaultProvider: { name: 'native', factory: defaultFactory },
|
|
10
|
+
registryHooks,
|
|
11
|
+
localProviders: [{ name: 'custom', factory: localFactory }],
|
|
12
|
+
})();
|
|
13
|
+
const api = { getRuntimeConfig: () => ({ router: { framework: 'custom' } }) };
|
|
14
|
+
expect(plugin.registryHooks).toBe(registryHooks);
|
|
15
|
+
plugin.setup(api);
|
|
16
|
+
expect(localFactory).toHaveBeenCalledWith({});
|
|
17
|
+
expect(setup).toHaveBeenCalledWith(api);
|
|
18
|
+
expect(defaultFactory).not.toHaveBeenCalled();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('rejects missing local providers', () => {
|
|
22
|
+
const factory = createRouterPlugin({
|
|
23
|
+
defaultProvider: { name: 'native', factory: () => ({}) },
|
|
24
|
+
registryHooks: {},
|
|
25
|
+
});
|
|
26
|
+
expect(() =>
|
|
27
|
+
factory({ framework: 'foreign' }).setup({ getRuntimeConfig: () => ({}) }),
|
|
28
|
+
).toThrow(/app-owned router provider realm/);
|
|
29
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyRouterRuntimeState,
|
|
3
|
+
applyRouterServerPrepareResult,
|
|
4
|
+
cleanupRouterRuntimeState,
|
|
5
|
+
getRouterHydrationScripts,
|
|
6
|
+
getRouterMatchedRouteIds,
|
|
7
|
+
getRouterRuntimeState,
|
|
8
|
+
getRouterServerSnapshot,
|
|
9
|
+
} from '../src/routerState';
|
|
10
|
+
|
|
11
|
+
describe('router state primitives', () => {
|
|
12
|
+
it('preserves snapshot precedence and a captured snapshot across client state updates', () => {
|
|
13
|
+
const context = { requestId: 'one' };
|
|
14
|
+
expect(
|
|
15
|
+
applyRouterRuntimeState(context, {
|
|
16
|
+
framework: 'custom',
|
|
17
|
+
basename: '/client',
|
|
18
|
+
hydrationScripts: ['client'],
|
|
19
|
+
matchedRouteIds: ['client-route'],
|
|
20
|
+
serverSnapshot: {
|
|
21
|
+
basename: '/server',
|
|
22
|
+
hydrationScripts: ['server'],
|
|
23
|
+
matchedRouteIds: ['server-route'],
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
).toBe(context);
|
|
27
|
+
const snapshot = getRouterServerSnapshot(context);
|
|
28
|
+
expect(snapshot).toMatchObject({
|
|
29
|
+
framework: 'custom',
|
|
30
|
+
basename: '/server',
|
|
31
|
+
hydrationScripts: ['server'],
|
|
32
|
+
matchedRouteIds: ['server-route'],
|
|
33
|
+
});
|
|
34
|
+
expect(getRouterHydrationScripts(context)).toEqual(['server']);
|
|
35
|
+
expect(getRouterMatchedRouteIds(context)).toEqual(['server-route']);
|
|
36
|
+
|
|
37
|
+
applyRouterRuntimeState(context, {
|
|
38
|
+
framework: 'custom',
|
|
39
|
+
instance: { client: true },
|
|
40
|
+
});
|
|
41
|
+
expect(getRouterRuntimeState(context)?.serverSnapshot).toBeUndefined();
|
|
42
|
+
expect(getRouterServerSnapshot(context)).toBe(snapshot);
|
|
43
|
+
expect(getRouterHydrationScripts(context)).toEqual(['server']);
|
|
44
|
+
expect(getRouterMatchedRouteIds(context)).toEqual(['server-route']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('applies server prepare overrides and awaits cleanup without propagating failures', async () => {
|
|
48
|
+
const context = {};
|
|
49
|
+
const stateCleanup = rstest.fn();
|
|
50
|
+
const cleanup = rstest.fn(async () => {
|
|
51
|
+
await Promise.resolve();
|
|
52
|
+
});
|
|
53
|
+
expect(
|
|
54
|
+
applyRouterServerPrepareResult(context, {
|
|
55
|
+
state: {
|
|
56
|
+
framework: 'custom',
|
|
57
|
+
cleanup: stateCleanup,
|
|
58
|
+
serverSnapshot: { statusCode: 200 },
|
|
59
|
+
},
|
|
60
|
+
snapshot: { statusCode: 299 },
|
|
61
|
+
cleanup,
|
|
62
|
+
}),
|
|
63
|
+
).toBe(context);
|
|
64
|
+
expect(getRouterServerSnapshot(context)?.statusCode).toBe(299);
|
|
65
|
+
await cleanupRouterRuntimeState(context);
|
|
66
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(stateCleanup).not.toHaveBeenCalled();
|
|
68
|
+
|
|
69
|
+
applyRouterRuntimeState(context, {
|
|
70
|
+
framework: 'custom',
|
|
71
|
+
cleanup: async () => {
|
|
72
|
+
throw new Error('cleanup failed');
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
await expect(cleanupRouterRuntimeState(context)).resolves.toBeUndefined();
|
|
76
|
+
await expect(cleanupRouterRuntimeState({})).resolves.toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getRouterRuntimeState,
|
|
3
|
+
getRouterServerSnapshot,
|
|
4
|
+
} from '../src/routerState';
|
|
5
|
+
import { createRouterStatePlugin } from '../src/routerStatePlugin';
|
|
6
|
+
|
|
7
|
+
function install() {
|
|
8
|
+
const registryHooks = { onAfterCreateRouter: { call() {} } };
|
|
9
|
+
const plugin = createRouterStatePlugin({ registryHooks });
|
|
10
|
+
let beforeRender!: (context: object) => void;
|
|
11
|
+
let afterCreate!: Parameters<
|
|
12
|
+
Parameters<typeof plugin.setup>[0]['onAfterCreateRouter']
|
|
13
|
+
>[0];
|
|
14
|
+
plugin.setup({
|
|
15
|
+
onBeforeRender: callback => {
|
|
16
|
+
beforeRender = callback;
|
|
17
|
+
},
|
|
18
|
+
onAfterCreateRouter: callback => {
|
|
19
|
+
afterCreate = callback;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return { plugin, registryHooks, beforeRender, afterCreate };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('keeps injected hooks and per-context policies stable without sharing app values', () => {
|
|
26
|
+
const { beforeRender } = install();
|
|
27
|
+
const first: any = {};
|
|
28
|
+
const second: any = {};
|
|
29
|
+
beforeRender(first);
|
|
30
|
+
const policy = first.linkPrefetchPolicy;
|
|
31
|
+
beforeRender(first);
|
|
32
|
+
beforeRender(second);
|
|
33
|
+
expect(first.linkPrefetchPolicy).toBe(policy);
|
|
34
|
+
expect(second.linkPrefetchPolicy).not.toBe(policy);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('projects loader data, errors and valid route ids from injected SSR input', () => {
|
|
38
|
+
const { afterCreate } = install();
|
|
39
|
+
const errors = { route: new Error('route error') };
|
|
40
|
+
const context = {
|
|
41
|
+
routerContext: {
|
|
42
|
+
statusCode: 503,
|
|
43
|
+
errors,
|
|
44
|
+
loaderData: { root: { ok: true } },
|
|
45
|
+
matches: [{ route: { id: 'root' } }, { route: {} }],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
afterCreate({
|
|
49
|
+
framework: 'react-router',
|
|
50
|
+
phase: 'ssr-prepare',
|
|
51
|
+
runtimeContext: context,
|
|
52
|
+
router: context.routerContext,
|
|
53
|
+
basename: '/nested',
|
|
54
|
+
});
|
|
55
|
+
expect(getRouterRuntimeState(context)?.instance).toBe(context.routerContext);
|
|
56
|
+
expect(getRouterServerSnapshot(context)).toMatchObject({
|
|
57
|
+
framework: 'react-router',
|
|
58
|
+
basename: '/nested',
|
|
59
|
+
statusCode: 503,
|
|
60
|
+
matchedRouteIds: ['root'],
|
|
61
|
+
routerData: { loaderData: context.routerContext.loaderData, errors },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createRuntimeContextExtension } from '../src/contextExtensions';
|
|
2
|
+
import { projectRuntimeContext } from '../src/runtimeContextProjection';
|
|
3
|
+
|
|
4
|
+
describe('fork runtime context projection', () => {
|
|
5
|
+
it('removes copied enumerable extension state only from the public projection', () => {
|
|
6
|
+
const context = { isBrowser: false, requestId: 'original' };
|
|
7
|
+
const extension = createRuntimeContextExtension<object>('test:projection');
|
|
8
|
+
const state = { router: 'private' };
|
|
9
|
+
extension.set(context, state);
|
|
10
|
+
const publicInput = { ...context };
|
|
11
|
+
|
|
12
|
+
const result = projectRuntimeContext(
|
|
13
|
+
{ internalContext: context, publicContext: publicInput },
|
|
14
|
+
{ context, isRsc: false },
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(result.internalContext).toBe(context);
|
|
18
|
+
expect(result.publicContext).not.toBe(publicInput);
|
|
19
|
+
expect(extension.get(result.internalContext)).toBe(state);
|
|
20
|
+
expect(extension.get(publicInput)).toBe(state);
|
|
21
|
+
expect(extension.get(result.publicContext)).toBeUndefined();
|
|
22
|
+
expect(Object.getOwnPropertySymbols(result.publicContext)).not.toContain(
|
|
23
|
+
Symbol.for('@modern-js/runtime:context-extensions'),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('projects RSC request fields and locals without mutating the original response', () => {
|
|
28
|
+
const response = {
|
|
29
|
+
setHeader: rstest.fn(),
|
|
30
|
+
status: rstest.fn(),
|
|
31
|
+
locals: { tenant: 'tractor-store' },
|
|
32
|
+
};
|
|
33
|
+
const request = {
|
|
34
|
+
params: { category: 'compact' },
|
|
35
|
+
pathname: '/tractors',
|
|
36
|
+
query: { sort: 'price' },
|
|
37
|
+
headers: { accept: 'text/html' },
|
|
38
|
+
host: 'example.test',
|
|
39
|
+
url: 'https://example.test/tractors?sort=price',
|
|
40
|
+
raw: new Request('https://example.test/tractors'),
|
|
41
|
+
};
|
|
42
|
+
const context = { isBrowser: false, ssrContext: { request, response } };
|
|
43
|
+
const extension = createRuntimeContextExtension<object>('test:rsc');
|
|
44
|
+
const state = { original: true };
|
|
45
|
+
extension.set(context, state);
|
|
46
|
+
const publicContext = { isBrowser: false };
|
|
47
|
+
const result = projectRuntimeContext(
|
|
48
|
+
{ internalContext: context, publicContext },
|
|
49
|
+
{ context, isRsc: true },
|
|
50
|
+
);
|
|
51
|
+
const internal = result.internalContext as typeof result.internalContext & {
|
|
52
|
+
context: { request: object; response: typeof response };
|
|
53
|
+
requestContext: { request: object; response: typeof response };
|
|
54
|
+
};
|
|
55
|
+
const publicValue = result.publicContext as typeof internal;
|
|
56
|
+
|
|
57
|
+
expect(internal).not.toBe(context);
|
|
58
|
+
expect(internal.ssrContext).toBeUndefined();
|
|
59
|
+
expect(internal.requestContext).toBe(publicValue.requestContext);
|
|
60
|
+
expect(internal.context).toBe(internal.requestContext);
|
|
61
|
+
expect(publicValue.context).toBe(internal.requestContext);
|
|
62
|
+
expect(internal.requestContext.response.locals).toBe(response.locals);
|
|
63
|
+
expect(context.ssrContext.response).toBe(response);
|
|
64
|
+
expect(extension.get(context)).toBe(state);
|
|
65
|
+
// Internal RSC copy behavior is retained pending separate Flight proof.
|
|
66
|
+
expect(extension.get(internal)).toBe(state);
|
|
67
|
+
expect(extension.get(publicValue)).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not apply server request projection to an RSC browser context', () => {
|
|
71
|
+
const context = {
|
|
72
|
+
isBrowser: true,
|
|
73
|
+
ssrContext: { request: {}, response: { locals: { browser: true } } },
|
|
74
|
+
};
|
|
75
|
+
const result = projectRuntimeContext(
|
|
76
|
+
{ internalContext: context, publicContext: { ...context } },
|
|
77
|
+
{ context, isRsc: true },
|
|
78
|
+
);
|
|
79
|
+
expect(result.internalContext).toBe(context);
|
|
80
|
+
expect(result.internalContext.ssrContext).toBe(context.ssrContext);
|
|
81
|
+
expect(result.publicContext).not.toHaveProperty('requestContext');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -10,9 +10,7 @@ const readBody = async (response: Response) =>
|
|
|
10
10
|
|
|
11
11
|
describe('safe failure responses', () => {
|
|
12
12
|
it.each([
|
|
13
|
-
[404, 'REQUEST_FAILED', 'Request failed'],
|
|
14
13
|
[500, 'INTERNAL_SERVER_ERROR', 'Internal Server Error'],
|
|
15
|
-
[503, 'SERVICE_UNAVAILABLE', 'Service Unavailable'],
|
|
16
14
|
])('redacts a %i failure through every response surface', async (status, code, message) => {
|
|
17
15
|
const error = Object.assign(new Error('database-password'), {
|
|
18
16
|
status,
|
|
@@ -54,23 +52,6 @@ describe('safe failure responses', () => {
|
|
|
54
52
|
|
|
55
53
|
it.each([
|
|
56
54
|
['delay seconds text', ' 120 ', '120'],
|
|
57
|
-
['leading-zero delay text', '000120', '120'],
|
|
58
|
-
['delay seconds number', 2.1, '3'],
|
|
59
|
-
[
|
|
60
|
-
'largest safe delay number',
|
|
61
|
-
Number.MAX_SAFE_INTEGER,
|
|
62
|
-
String(Number.MAX_SAFE_INTEGER),
|
|
63
|
-
],
|
|
64
|
-
[
|
|
65
|
-
'largest safe delay text',
|
|
66
|
-
String(Number.MAX_SAFE_INTEGER),
|
|
67
|
-
String(Number.MAX_SAFE_INTEGER),
|
|
68
|
-
],
|
|
69
|
-
[
|
|
70
|
-
'canonical HTTP-date text',
|
|
71
|
-
'Sun, 30 Aug 2026 20:00:00 GMT',
|
|
72
|
-
'Sun, 30 Aug 2026 20:00:00 GMT',
|
|
73
|
-
],
|
|
74
55
|
[
|
|
75
56
|
'valid Date object',
|
|
76
57
|
new Date(Date.UTC(2026, 7, 30, 20, 0, 0)),
|
|
@@ -81,6 +62,16 @@ describe('safe failure responses', () => {
|
|
|
81
62
|
expect(result.headers['Retry-After']).toBe(expected);
|
|
82
63
|
});
|
|
83
64
|
|
|
65
|
+
it('derives Retry-After from a retryAfterSeconds error property', () => {
|
|
66
|
+
const error = Object.assign(new Error('maintenance'), {
|
|
67
|
+
status: 503,
|
|
68
|
+
retryAfterSeconds: 120,
|
|
69
|
+
});
|
|
70
|
+
expect(createSafeFailureHttpResult(error).headers['Retry-After']).toBe(
|
|
71
|
+
'120',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
84
75
|
it('converts Retry-After milliseconds to canonical delay seconds', () => {
|
|
85
76
|
expect(
|
|
86
77
|
createSafeFailureHttpResult({ status: 503, retryAfterMs: 2500 }).headers[
|
|
@@ -91,15 +82,6 @@ describe('safe failure responses', () => {
|
|
|
91
82
|
|
|
92
83
|
it.each([
|
|
93
84
|
['a header injection payload', '120\r\nX-Injected: true'],
|
|
94
|
-
['arbitrary text', 'after maintenance'],
|
|
95
|
-
['an exponent string', '1e3'],
|
|
96
|
-
['a decimal string', '2.1'],
|
|
97
|
-
['a negative string', '-1'],
|
|
98
|
-
['an unsafe integer string', '9007199254740992'],
|
|
99
|
-
['an unsafe number', 1e21],
|
|
100
|
-
['infinity', Number.POSITIVE_INFINITY],
|
|
101
|
-
['a noncanonical date', 'Sunday, 30-Aug-26 20:00:00 GMT'],
|
|
102
|
-
['an invalid date string', 'Wed, 99 Jun 2026 25:61:61 GMT'],
|
|
103
85
|
['an invalid Date object', new Date(Number.NaN)],
|
|
104
86
|
])('rejects %s as Retry-After', (_, retryAfter) => {
|
|
105
87
|
const result = createSafeFailureHttpResult({ status: 503, retryAfter });
|
|
@@ -83,3 +83,40 @@ describe('conserving web shell stream', () => {
|
|
|
83
83
|
expect(published).toEqual(['previous']);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
test('cancellation waits for the owned source cancellation to finish once', async () => {
|
|
88
|
+
let release!: () => void;
|
|
89
|
+
let started!: () => void;
|
|
90
|
+
const cancelling = new Promise<void>(resolve => {
|
|
91
|
+
started = resolve;
|
|
92
|
+
});
|
|
93
|
+
const gate = new Promise<void>(resolve => {
|
|
94
|
+
release = resolve;
|
|
95
|
+
});
|
|
96
|
+
const cancel = rstest.fn(async () => {
|
|
97
|
+
started();
|
|
98
|
+
await gate;
|
|
99
|
+
});
|
|
100
|
+
const source = new ReadableStream<Uint8Array>({ cancel });
|
|
101
|
+
const reader = createConservingWebShellStream(source, {}, MARKER).getReader();
|
|
102
|
+
let settled = false;
|
|
103
|
+
const cancellation = reader.cancel('stop').then(() => {
|
|
104
|
+
settled = true;
|
|
105
|
+
});
|
|
106
|
+
await cancelling;
|
|
107
|
+
expect(settled).toBe(false);
|
|
108
|
+
release();
|
|
109
|
+
await cancellation;
|
|
110
|
+
expect(cancel).toHaveBeenCalledExactlyOnceWith('stop');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('cancellation propagates an owned source cleanup failure', async () => {
|
|
114
|
+
const source = new ReadableStream<Uint8Array>({
|
|
115
|
+
cancel() {
|
|
116
|
+
throw new Error('cleanup failed');
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
await expect(
|
|
120
|
+
createConservingWebShellStream(source, {}, MARKER).cancel('stop'),
|
|
121
|
+
).rejects.toThrow('cleanup failed');
|
|
122
|
+
});
|