diff --git a/README.md b/README.md index 494a161b..f36b5d92 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,16 @@ The `is_preload=True` option in the `render_bundle` template tag can be used to ``` +### Skipping the generation of multiple common chunks + +You can use the parameter `skip_common_chunks=True` to specify that you don't want an already generated chunk be generated again in the same page. + +In order for this option to work, django-webpack-loader requires the `request` object to be in the context, to be able to keep track of the generated chunks. + +The `request` object is passed by default via the `django.template.context_processors.request` middleware with using the Django built-in templating system, and also with using Jinja2. + +If you don't have `request` in the context for some reason (e.g. using `Template.render` or `render_to_string` directly without passing the request), you'll get warnings on the console. + ### Appending file extensions The `suffix` option can be used to append a string at the end of the file URL. For instance, it can be used if your webpack configuration emits compressed `.gz` files. diff --git a/tests/app/templates/home-duplicated.jinja b/tests/app/templates/home-duplicated.jinja new file mode 100644 index 00000000..e9fb9a05 --- /dev/null +++ b/tests/app/templates/home-duplicated.jinja @@ -0,0 +1,15 @@ + + + + + Example + {{ render_bundle('app1', 'js') }} + {{ render_bundle('app2', 'js') }} + + + +
+ {{ render_bundle('app1', 'js') }} + {{ render_bundle('app2', 'js') }} + + diff --git a/tests/app/tests/test_webpack.py b/tests/app/tests/test_webpack.py index c48df99e..9d26fc4a 100644 --- a/tests/app/tests/test_webpack.py +++ b/tests/app/tests/test_webpack.py @@ -4,19 +4,24 @@ from shutil import rmtree from subprocess import call from threading import Thread +from unittest.mock import Mock +from unittest.mock import call as MockCall +from unittest.mock import patch from django.conf import settings -from django.template import engines -from django.template.backends.django import Template +from django.template import Context, Template, engines from django.template.response import TemplateResponse from django.test.client import RequestFactory from django.test.testcases import TestCase from django.views.generic.base import TemplateView +from django_jinja.backend import Jinja2 +from django_jinja.backend import Template as Jinja2Template from django_jinja.builtins import DEFAULT_EXTENSIONS from webpack_loader.exceptions import (WebpackBundleLookupError, WebpackError, WebpackLoaderBadStatsError, WebpackLoaderTimeoutError) +from webpack_loader.templatetags.webpack_loader import _WARNING_MESSAGE from webpack_loader.utils import get_loader BUNDLE_PATH = os.path.join( @@ -24,6 +29,8 @@ DEFAULT_CONFIG = 'DEFAULT' _OUR_EXTENSION = 'webpack_loader.contrib.jinja2ext.WebpackExtension' +_warn_mock = Mock() + class LoaderTestCase(TestCase): def setUp(self): @@ -333,6 +340,142 @@ def test_request_blocking(self): elapsed = time.time() - then self.assertTrue(elapsed < wait_for) + @patch( + target='webpack_loader.templatetags.webpack_loader.warn', + new=_warn_mock) + def test_emits_warning_on_no_request_in_djangoengine(self): + """ + Should emit warnings on having no request in context (django + template). + """ + self.compile_bundles('webpack.config.skipCommon.js') + asset_vendor = ( + '') + asset_app1 = ( + '') + asset_app2 = ( + '') + + # Shouldn't call any `warn()` here + dups_template = Template(template_string=( + r'{% load render_bundle from webpack_loader %}' + r'{% render_bundle "app1" %}' + r'{% render_bundle "app2" %}')) # type: Template + output = dups_template.render(context=Context()) + _warn_mock.assert_not_called() + self.assertEqual(output.count(asset_app1), 1) + self.assertEqual(output.count(asset_app2), 1) + self.assertEqual(output.count(asset_vendor), 2) + + # Should call `warn()` here + nodups_template = Template(template_string=( + r'{% load render_bundle from webpack_loader %}' + r'{% render_bundle "app1" %}' + r'{% render_bundle "app2" skip_common_chunks=True %}') + ) # type: Template + output = nodups_template.render(context=Context()) + self.assertEqual(output.count(asset_app1), 1) + self.assertEqual(output.count(asset_app2), 1) + self.assertEqual(output.count(asset_vendor), 2) + _warn_mock.assert_called_once_with( + message=_WARNING_MESSAGE, category=RuntimeWarning) + + # Should NOT call `warn()` here + _warn_mock.reset_mock() + nodups_template = Template(template_string=( + r'{% load render_bundle from webpack_loader %}' + r'{% render_bundle "app1" %}' + r'{% render_bundle "app2" skip_common_chunks=True %}') + ) # type: Template + request = self.factory.get(path='/') + output = nodups_template.render(context=Context({'request': request})) + used_tags = getattr(request, '_webpack_loader_used_tags', None) + self.assertIsNotNone(used_tags, msg=( + '_webpack_loader_used_tags should be a property of request!')) + self.assertEqual(output.count(asset_app1), 1) + self.assertEqual(output.count(asset_app2), 1) + self.assertEqual(output.count(asset_vendor), 1) + _warn_mock.assert_not_called() + _warn_mock.reset_mock() + + @patch( + target='webpack_loader.templatetags.webpack_loader.warn', + new=_warn_mock) + def test_emits_warning_on_no_request_in_jinja2engine(self): + 'Should emit warnings on having no request in context (Jinja2).' + self.compile_bundles('webpack.config.skipCommon.js') + settings = { + 'TEMPLATES': [ + { + 'NAME': 'jinja2', + 'BACKEND': 'django_jinja.backend.Jinja2', + 'APP_DIRS': True, + 'OPTIONS': { + 'match_extension': '.jinja', + 'extensions': DEFAULT_EXTENSIONS + [_OUR_EXTENSION], + } + }, + ] + } + asset_vendor = ( + '') + asset_app1 = ( + '') + asset_app2 = ( + '') + warning_call = MockCall( + message=_WARNING_MESSAGE, category=RuntimeWarning) + + # Shouldn't call any `warn()` here + with self.settings(**settings): + jinja2_engine = engines['jinja2'] # type: Jinja2 + dups_template = jinja2_engine.get_template( + template_name='home-duplicated.jinja') # type: Jinja2Template + output = dups_template.render() + _warn_mock.assert_not_called() + self.assertEqual(output.count(asset_app1), 2) + self.assertEqual(output.count(asset_app2), 2) + self.assertEqual(output.count(asset_vendor), 4) + + # Should call `warn()` here + with self.settings(**settings): + jinja2_engine = engines['jinja2'] # type: Jinja2 + nodups_template = jinja2_engine.get_template( + template_name='home-deduplicated.jinja' + ) # type: Jinja2Template + output = nodups_template.render() + self.assertEqual(output.count(asset_app1), 2) + self.assertEqual(output.count(asset_app2), 2) + self.assertEqual(output.count(asset_vendor), 4) + self.assertEqual(_warn_mock.call_count, 3) + self.assertListEqual( + _warn_mock.call_args_list, + [warning_call, warning_call, warning_call]) + + # Should NOT call `warn()` here + _warn_mock.reset_mock() + request = self.factory.get(path='/') + with self.settings(**settings): + jinja2_engine = engines['jinja2'] # type: Jinja2 + nodups_template = jinja2_engine.get_template( + template_name='home-deduplicated.jinja' + ) # type: Jinja2Template + output = nodups_template.render(request=request) + used_tags = getattr(request, '_webpack_loader_used_tags', None) + self.assertIsNotNone(used_tags, msg=( + '_webpack_loader_used_tags should be a property of request!')) + self.assertEqual(output.count(asset_app1), 1) + self.assertEqual(output.count(asset_app2), 1) + self.assertEqual(output.count(asset_vendor), 1) + _warn_mock.assert_not_called() + _warn_mock.reset_mock() + def test_skip_common_chunks_djangoengine(self): """Test case for deduplication of modules with the django engine.""" self.compile_bundles('webpack.config.skipCommon.js') diff --git a/webpack_loader/templatetags/webpack_loader.py b/webpack_loader/templatetags/webpack_loader.py index 60502c43..64e4d627 100644 --- a/webpack_loader/templatetags/webpack_loader.py +++ b/webpack_loader/templatetags/webpack_loader.py @@ -1,31 +1,16 @@ -from django.conf import settings +from warnings import warn + from django.template import Library from django.utils.safestring import mark_safe from .. import utils register = Library() -_PROC_DJTEMPLATE = 'django.template.context_processors.request' -_DJ_TEMPLATEPROCESSOR = 'django.template.backends.django.DjangoTemplates' -_STARTUP_ERROR = ( - f'Please make sure that "{_PROC_DJTEMPLATE}" is added ' - 'to your ["OPTIONS"]["context_processors"] list in your ' - f'settings.TEMPLATES where the BACKEND is "{_DJ_TEMPLATEPROCESSOR}". ' - 'django-webpack-loader needs it and cannot run without it.') - - -def _is_request_in_context(): - for item in settings.TEMPLATES: - backend = item.get('BACKEND', {}) - if backend == _DJ_TEMPLATEPROCESSOR: - processors = set( - item.get('OPTIONS', {}).get('context_processors', [])) - if _PROC_DJTEMPLATE not in processors: - raise RuntimeError(_STARTUP_ERROR) - - -# Check settings at module import time -_is_request_in_context() +_WARNING_MESSAGE = ( + 'You have specified skip_common_chunks=True but the passed context ' + 'doesn\'t have a request. django_webpack_loader needs a request object to ' + 'filter out duplicate chunks. Please see https://github.com/django-webpack' + '/django-webpack-loader#skipping-the-generation-of-multiple-common-chunks') @register.simple_tag(takes_context=True) @@ -35,15 +20,17 @@ def render_bundle( tags = utils.get_as_tags( bundle_name, extension=extension, config=config, suffix=suffix, attrs=attrs, is_preload=is_preload) - - if not hasattr(context['request'], '_webpack_loader_used_tags'): - context['request']._webpack_loader_used_tags = set() - - used_tags = context['request']._webpack_loader_used_tags + request = context.get('request') + if request is None: + if skip_common_chunks: + warn(message=_WARNING_MESSAGE, category=RuntimeWarning) + return mark_safe('\n'.join(tags)) + used_tags = getattr(context['request'], '_webpack_loader_used_tags', None) + if not used_tags: + used_tags = context['request']._webpack_loader_used_tags = set() if skip_common_chunks: tags = [tag for tag in tags if tag not in used_tags] used_tags.update(tags) - return mark_safe('\n'.join(tags))