mozilla / nunjucks

A powerful templating engine with inheritance, asynchronous control, and more (jinja2 inspired)
https://mozilla.github.io/nunjucks/
BSD 2-Clause "Simplified" License
8.55k stars 638 forks source link

Async filters don't work if render callback is not used #1474

Open olegsmetanin opened 3 weeks ago

olegsmetanin commented 3 weeks ago

"nunjucks": "^3.2.4",

Async filter

.addFilter('await', async function (value, cb) {
  const res = await new Promise((_resolve, _reject) => {
    _resolve(value)
  })
  cb(null, res)
}, true)

with template

{% asyncEach rec in ["qwe"] | await %}
{{ rec }}
{% endeach %}

doesn't work and renders nothing

geleto commented 2 weeks ago

This is how I do it:

.addFilter('await', function (promise: Promise<any>, callback: (err: Error | null, result?: any) => void) {
    promise.then(result => {
        callback(null, result);
    }).catch(err => {
        callback(err);
    });
}, true);
olegsmetanin commented 2 weeks ago

@geleto it doesn't work for any real async case, just try to send setTimeout promise with 0 delay to this filter. I expect the following filter will work and render "result":

.addFilter('await', function (_in: any, callback: (err: Error | null, result?: any) => void) {
    const promise = new Promise((resolve) => {
          setTimeout(() => {
            resolve('result')
          }, 0)
        });
        promise.then(result => {
        callback(null, result);
    }).catch(err => {
        callback(err);
    });
}, true);
geleto commented 2 weeks ago

Here are some example tests for real async cases:

import { expect } from 'chai';
import * as nunjucks from 'nunjucks';

describe('await filter tests', () => {

    let env: nunjucks.Environment;

    beforeEach(() => {
        env = new nunjucks.Environment();
        // Implement 'await' filter for resolving promises in templates
        env.addFilter('await', function (promise: Promise<any>, callback: (err: Error | null, result?: any) => void) {
            promise.then(result => {
                callback(null, result);
            }).catch(err => {
                callback(err);
            });
        }, true);
    });

    it('should handle custom async filter with global async function', (done) => {
        // Add global async function
        env.addGlobal('fetchUser', async (id: number) => {
            // Simulate async operation
            await new Promise(resolve => setTimeout(resolve, 10));
            return { id, name: `User ${id}` };
        });

        const template = '{% set user = fetchUser(123) | await %}Hello, {{ user.name }}!';

        env.renderString(template, {}, (err, result) => {
            if (err) return done(err);
            expect(result).to.equal('Hello, User 123!');
            done();
        });
    });

    it('should handle asyncEach with array of promises', (done) => {
        // Add global function to fetch records
        env.addGlobal('getRecords', () => {
            // Return an array of promises (each record is a promise)
            return [
                new Promise(resolve => setTimeout(() => resolve('Record 1'), 10)),
                new Promise(resolve => setTimeout(() => resolve('Record 2'), 20)),
                new Promise(resolve => setTimeout(() => resolve('Record 3'), 15))
            ];
        });

        const template = `{%- set records = getRecords() -%}
        {%- asyncEach rec in records -%}
        {{ rec | await }}{% if not loop.last %}\n{% endif %}
        {%- endeach %}`;;

        env.renderString(template, {}, (err, result) => {
            if (err) return done(err);
            expect((result as string).trim()).to.equal('Record 1\nRecord 2\nRecord 3');
            done();
        });
    });
});
olegsmetanin commented 2 weeks ago

@geleto Thank you! It seems that we can't call renderString without callback if we have async filters

import { expect } from 'chai';
import * as nunjucks from 'nunjucks';

describe('await filter tests', () => {

    let env: nunjucks.Environment;

    beforeEach(() => {
        env = new nunjucks.Environment();

        env.addFilter('fetchUser', async (id: string, callback: (err: Error | null, result?: any) => void) => {
            // Simulate async operation
            const promise = new Promise(resolve => setTimeout(() => resolve(`User ${id}`), 10));
            promise.then(result => {
                callback(null, result);
            }).catch(err => {
                callback(err);
            });
        }, true);
    });

    it('should render using renderString without callback', () => {
        const template = '{% set user = "User 123" %}Hello, {{ user }}!';
        const result = env.renderString(template, {});
        expect(result).to.equal('Hello, User 123!');
    });

    it('should handle custom async filter using renderString with callback', (done) => {
        const template = '{% set user = "123" | fetchUser %}Hello, {{ user }}!';
        env.renderString(template, {}, (err, result) => {
            if (err) return done(err);
            expect(result).to.equal('Hello, User 123!');
            done();
        });
    });

    // Failed!
    it('should handle custom async filter using renderString without callback', () => {
        const template = '{% set user = "123" | fetchUser %}Hello, {{ user }}!';
        const result = env.renderString(template, {});
        expect(result).to.equal('Hello, User 123!');
    });

});

  await filter tests
    ✔ should render using renderString without callback
    ✔ should handle custom async filter using renderString with callback
    1) should handle custom async filter using renderString without callback

  2 passing (24ms)
  1 failing

  1) await filter tests
       should handle custom async filter using renderString without callback:
     AssertionError: expected null to equal 'Hello, User 123!'