Compare commits

..

24 Commits

Author SHA1 Message Date
Nicolas Bayet d0cdc489b1 up 2025-10-02 15:22:13 +02:00
Nicolas Bayet a309ec498b up 2025-10-01 13:57:58 +02:00
Nicolas Bayet 1ddf2ceff9 up 2025-10-01 13:55:40 +02:00
Nicolas Bayet 38574a1f3a no reactivity in setup 2025-09-29 18:50:26 +02:00
Nicolas Bayet 6f2600ba5f md 2025-09-29 18:50:16 +02:00
Nicolas Bayet ed9db6ea2a withoutReactivity 2025-09-29 13:34:58 +02:00
Nicolas Bayet 0c1f9f3ffa fix for dropdown 2025-09-28 14:57:49 +02:00
Nicolas Bayet 4385969e2e up 2025-09-26 18:58:15 +02:00
Nicolas Bayet e31d195bb7 up 2025-09-26 18:38:40 +02:00
Nicolas Bayet 836fdada7f up 2025-09-26 18:31:03 +02:00
Nicolas Bayet 22580661c4 up 2025-09-26 18:27:22 +02:00
Nicolas Bayet adb9d405bb up 2025-09-26 18:08:15 +02:00
Nicolas Bayet 8d12bf17dc comment 2025-09-26 16:06:50 +02:00
Nicolas Bayet c74c66ab3b up 2025-09-26 15:37:51 +02:00
Romeo Fragomeli 5187f01c44 [REL] v2.8.1
# v2.8.1

 - [FIX] parser, code_generator: wrapped children recognition
 - [DOC] `slots` prop validation
2025-09-23 09:17:54 +02:00
Mathieu Duckerts-Antoine 521111644c [FIX] parser, code_generator: wrapped children recognition
Several directives (t-key, t-log, t-translation, …) are represented by
wrapper ASTs that contain another AST or null.
Because these wrappers don't share the same type as their children,
various AST type checks were broken.

This commit addresses those issues by:

- Commuting Translation / TranslationContext and Multi ASTs so that
  Multi children are spread as expected (see parseChildren).

- Parsing the t-key directive before t-esc / t-out (in line with https://github.com/odoo/owl/pull/1685).

- Ensuring wrappers around TSet ASTs are recognized as having no
  representation, so compileMulti properly discards children equivalent
  to TSet.
2025-09-22 16:59:47 +02:00
Damien Bouvy c2728c9daf [DOC] slots prop validation
Make it explicit that props validation should accept `slots` if a component uses slots (even the `default` slot).
2025-09-03 13:46:45 +02:00
Romeo Fragomeli 871dad6a13 [REL] v2.8.0
# v2.8.0

 - [FIX] parser: t-esc/t-out vs t-translation*
 - [IMP] compiler: make human-readable ARIA attributes translatable
2025-06-30 14:46:16 +02:00
Mathieu Duckerts-Antoine b620502a0f [FIX] parser: t-esc/t-out vs t-translation*
The directives t-esc/t-out both discard the ASTs that are not of DomNode
type. Since the directives t-translation and t-translation-context both
creates AST wrappers of type TTranslation and TTranslationContext
respectively, mix t-esc/t-out and t-translation/t-translation-context
does not work. For example parse

<span t-esc="'Hello'" t-translation="off"/>

gives the AST

{
    type: ASTType.TEsc,
    expr: "'Hello'",
    defaultValue: "",
}

This makes the span not been rendered in the end.

We fix that problem.
2025-06-24 16:03:56 +02:00
Louis Wicket (wil) 89cb00cc83 [IMP] compiler: make human-readable ARIA attributes translatable
ARIA attributes containing human-readable text should be translated.
This commit adds human-readable ARIA attributes to the list of the
attributes translated by OWL.
2025-04-03 21:40:27 +02:00
Romeo Fragomeli 56041bc133 [REL] v2.7.0
# v2.7.0

 - [IMP] runtime/utils: export htmlEscape and add tests
 - [FIX] utils: Correct validation of mount target in shadow DOM/iframe
 - [IMP] runtime: add markup tag function
2025-03-26 13:58:50 +01:00
Sébastien Theys e788e361c7 [IMP] runtime/utils: export htmlEscape and add tests
markup tag function requires markup awareness to determine whether a
given parameter should be escaped or not.

This implies that pre-escaped content should be properly marked'ed up to
avoid double escaping. Having to manually wrap all calls to escape
with markup is cumbersome and prone to issues (on top of having to be
validated by the security team for no reason).

This commit introduces a markup-aware escape function to resolve those
issues.
2025-03-26 13:52:35 +01:00
Achraf (abz) 9d378b0e7b [FIX] utils: Correct validation of mount target in shadow DOM/iframe
Previously, `validateTarget` only checked if the target element or its
host (if it was a ShadowRoot) was directly contained in the document body.
This failed in cases where the target element was nested inside a shadow
DOM, which itself was attached to the document.

This commit introduces a new helper `isAttachedToDocument` that
traverses through parent nodes and shadow roots to ensure that the
target is ultimately attached to the given document.
Additionally, it now throws a clear error if `document.defaultView` is
missing, indicating that the target document is detached or invalid.

This ensures proper validation of mount targets, including complex
scenarios with shadow roots and iframes.
2025-03-25 21:11:39 +01:00
Louis Wicket (wil) fd3c194525 [IMP] runtime: add markup tag function
Allows markup to be called as a tag function. The interpolated strings
are then safely escaped for injection in HTML code.

Example usage:
```js
const maliciousInput = "<script>alert('💥💥')</script>";
const value = markup`<b>${maliciousInput}</b>`;
// no problem, maliciousInput is properly escaped
```
2025-03-25 15:39:26 +01:00
39 changed files with 9281 additions and 5315 deletions
+22
View File
@@ -320,6 +320,28 @@ class ComponentB extends owl.Component {
Note: the props validation code is done by using the [validate utility function](utils.md#validate).
### `slots` prop
If a component that uses [slots](slots.md) also lists or validates its props, then
you will have to explicitely allow the `slots` prop (with an `Object` type), or
allow extra props using the `*` notation mentioned above. This is because slots
are provided to a component [as props](slots.md#slots-and-props).
For example:
```js
class MyComponent extends Component {
static props = [someProp, slots?];
}
class MyComponentWithValidation extends Component {
static props = {
someProp: {type: Number, optional: true},
slots : {type: Object, optional: true},
}
}
```
## Good Practices
A `props` object is a collection of values that come from the parent. As such,
+9
View File
@@ -193,6 +193,15 @@ The first `t-out` will act as a `t-esc` directive, which means that the content
of `value1` will be escaped. However, since `value2` has been tagged as a markup,
this will be injected as html.
`markup` can also be used as a tag function, allowing the interpolated values to
be safely escaped:
```js
const maliciousInput = "<script>alert('💥💥')</script>";
// <b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>
const value = markup`<b>${maliciousInput}</b>`;
```
### Setting Variables
QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ...
+146 -37
View File
@@ -276,13 +276,39 @@ function inOwnerDocument(el) {
const rootNode = el.getRootNode();
return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host);
}
/**
* Determine whether the given element is contained in a specific root documnet:
* either directly or with a shadow root in between or in an iframe.
*/
function isAttachedToDocument(element, documentElement) {
let current = element;
const shadowRoot = documentElement.defaultView.ShadowRoot;
while (current) {
if (current === documentElement) {
return true;
}
if (current.parentNode) {
current = current.parentNode;
}
else if (current instanceof shadowRoot && current.host) {
current = current.host;
}
else {
return false;
}
}
return false;
}
function validateTarget(target) {
// Get the document and HTMLElement corresponding to the target to allow mounting in iframes
const document = target && target.ownerDocument;
if (document) {
if (!document.defaultView) {
throw new OwlError("Cannot mount a component: the target document is not attached to a window (defaultView is missing)");
}
const HTMLElement = document.defaultView.HTMLElement;
if (target instanceof HTMLElement || target instanceof ShadowRoot) {
if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) {
if (!isAttachedToDocument(target, document)) {
throw new OwlError("Cannot mount a component on a detached dom node");
}
return;
@@ -319,12 +345,40 @@ async function loadFile(url) {
*/
class Markup extends String {
}
/*
* Marks a value as safe, that is, a value that can be injected as HTML directly.
* It should be used to wrap the value passed to a t-out directive to allow a raw rendering.
*/
function markup(value) {
return new Markup(value);
function htmlEscape(str) {
if (str instanceof Markup) {
return str;
}
if (str === undefined) {
return markup("");
}
if (typeof str === "number") {
return markup(String(str));
}
[
["&", "&amp;"],
["<", "&lt;"],
[">", "&gt;"],
["'", "&#x27;"],
['"', "&quot;"],
["`", "&#x60;"],
].forEach((pairs) => {
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
});
return markup(str);
}
function markup(valueOrStrings, ...placeholders) {
if (!Array.isArray(valueOrStrings)) {
return new Markup(valueOrStrings);
}
const strings = valueOrStrings;
let acc = "";
let i = 0;
for (; i < placeholders.length; ++i) {
acc += strings[i] + htmlEscape(placeholders[i]);
}
acc += strings[i];
return new Markup(acc);
}
function createEventHandler(rawEvent) {
@@ -3796,7 +3850,16 @@ class CodeTarget {
return key;
}
}
const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"];
const TRANSLATABLE_ATTRS = [
"alt",
"aria-label",
"aria-placeholder",
"aria-roledescription",
"aria-valuetext",
"label",
"placeholder",
"title",
];
const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
class CodeGenerator {
constructor(ast, options) {
@@ -4476,7 +4539,7 @@ class CodeGenerator {
const isNewBlock = !block || forceNewBlock;
let codeIdx = this.target.code.length;
if (isNewBlock) {
const n = ast.content.filter((c) => c.type !== 6 /* TSet */).length;
const n = ast.content.filter((c) => !c.hasNoRepresentation).length;
let result = null;
if (n <= 1) {
for (let child of ast.content) {
@@ -4490,15 +4553,15 @@ class CodeGenerator {
let index = 0;
for (let i = 0, l = ast.content.length; i < l; i++) {
const child = ast.content[i];
const isTSet = child.type === 6 /* TSet */;
const forceNewBlock = !child.hasNoRepresentation;
const subCtx = createContext(ctx, {
block,
index,
forceNewBlock: !isTSet,
forceNewBlock,
isLast: ctx.isLast && i === l - 1,
});
this.compileAST(child, subCtx);
if (!isTSet) {
if (forceNewBlock) {
index++;
}
}
@@ -4914,11 +4977,11 @@ function parseNode(node, ctx) {
parseTPortal(node, ctx) ||
parseTCall(node, ctx) ||
parseTCallBlock(node) ||
parseTEscNode(node, ctx) ||
parseTOutNode(node, ctx) ||
parseTKey(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTKey(node, ctx) ||
parseTEscNode(node, ctx) ||
parseTOutNode(node, ctx) ||
parseTSlot(node, ctx) ||
parseComponent(node, ctx) ||
parseDOMNode(node, ctx) ||
@@ -4986,19 +5049,29 @@ function parseTCustom(node, ctx) {
function parseTDebugLog(node, ctx) {
if (node.hasAttribute("t-debug")) {
node.removeAttribute("t-debug");
return {
const content = parseNode(node, ctx);
const ast = {
type: 12 /* TDebug */,
content: parseNode(node, ctx),
content,
};
if (content === null || content === void 0 ? void 0 : content.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
if (node.hasAttribute("t-log")) {
const expr = node.getAttribute("t-log");
node.removeAttribute("t-log");
return {
const content = parseNode(node, ctx);
const ast = {
type: 13 /* TLog */,
expr,
content: parseNode(node, ctx),
content,
};
if (content === null || content === void 0 ? void 0 : content.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
return null;
}
@@ -5228,11 +5301,19 @@ function parseTKey(node, ctx) {
}
const key = node.getAttribute("t-key");
node.removeAttribute("t-key");
const body = parseNode(node, ctx);
if (!body) {
const content = parseNode(node, ctx);
if (!content) {
return null;
}
return { type: 10 /* TKey */, expr: key, content: body };
const ast = {
type: 10 /* TKey */,
expr: key,
content,
};
if (content.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
// -----------------------------------------------------------------------------
// t-call
@@ -5341,7 +5422,7 @@ function parseTSetNode(node, ctx) {
if (node.textContent !== node.innerHTML) {
body = parseChildren(node, ctx);
}
return { type: 6 /* TSet */, name, value, defaultValue, body };
return { type: 6 /* TSet */, name, value, defaultValue, body, hasNoRepresentation: true };
}
// -----------------------------------------------------------------------------
// Components
@@ -5520,30 +5601,51 @@ function parseTSlot(node, ctx) {
// -----------------------------------------------------------------------------
// Translation
// -----------------------------------------------------------------------------
function wrapInTTranslationAST(r) {
const ast = { type: 16 /* TTranslation */, content: r };
if (r === null || r === void 0 ? void 0 : r.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
function parseTTranslation(node, ctx) {
if (node.getAttribute("t-translation") !== "off") {
return null;
}
node.removeAttribute("t-translation");
return {
type: 16 /* TTranslation */,
content: parseNode(node, ctx),
};
const result = parseNode(node, ctx);
if ((result === null || result === void 0 ? void 0 : result.type) === 3 /* Multi */) {
const children = result.content.map(wrapInTTranslationAST);
return makeASTMulti(children);
}
return wrapInTTranslationAST(result);
}
// -----------------------------------------------------------------------------
// Translation Context
// -----------------------------------------------------------------------------
function wrapInTTranslationContextAST(r, translationCtx) {
const ast = {
type: 17 /* TTranslationContext */,
content: r,
translationCtx,
};
if (r === null || r === void 0 ? void 0 : r.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
function parseTTranslationContext(node, ctx) {
const translationCtx = node.getAttribute("t-translation-context");
if (!translationCtx) {
return null;
}
node.removeAttribute("t-translation-context");
return {
type: 17 /* TTranslationContext */,
content: parseNode(node, ctx),
translationCtx,
};
const result = parseNode(node, ctx);
if ((result === null || result === void 0 ? void 0 : result.type) === 3 /* Multi */) {
const children = result.content.map((c) => wrapInTTranslationContextAST(c, translationCtx));
return makeASTMulti(children);
}
return wrapInTTranslationContextAST(result, translationCtx);
}
// -----------------------------------------------------------------------------
// Portal
@@ -5588,6 +5690,13 @@ function parseChildren(node, ctx) {
}
return children;
}
function makeASTMulti(children) {
const ast = { type: 3 /* Multi */, content: children };
if (children.every((c) => c.hasNoRepresentation)) {
ast.hasNoRepresentation = true;
}
return ast;
}
/**
* Parse all the child nodes of a given node and return an ast if possible.
* In the case there are multiple children, they are wrapped in a astmulti.
@@ -5600,7 +5709,7 @@ function parseChildNodes(node, ctx) {
case 1:
return children[0];
default:
return { type: 3 /* Multi */, content: children };
return makeASTMulti(children);
}
}
/**
@@ -5704,7 +5813,7 @@ function compile(template, options = {
}
// do not modify manually. This file is generated by the release script.
const version = "2.6.1";
const version = "2.8.1";
// -----------------------------------------------------------------------------
// Scheduler
@@ -6172,9 +6281,9 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
});
};
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, htmlEscape, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
__info__.date = '2025-03-05T08:37:58.580Z';
__info__.hash = '2b5cea9';
__info__.date = '2025-09-23T07:17:45.055Z';
__info__.hash = '5211116';
__info__.url = 'https://github.com/odoo/owl';
+6739 -3859
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.6.1",
"version": "2.8.1",
"description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js",
"module": "dist/owl.es.js",
@@ -22,7 +22,7 @@
"build:devtools-chrome": "npm run dev:devtools-chrome -- --config-env=production",
"build:devtools-firefox": "npm run dev:devtools-firefox -- --config-env=production",
"test": "jest",
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand --watch --testTimeout=5000000",
"test:debug": "node node_modules/.bin/jest --runInBand --watch --testTimeout=5000000",
"test:watch": "jest --watch",
"playground:serve": "python3 tools/playground_server.py || python tools/playground_server.py",
"playground": "npm run build && npm run playground:serve",
+18
View File
@@ -0,0 +1,18 @@
# encountered issues
## dropdown issue
- there was a problem that writing in a state while the effect was updated.
- the tracking of signal being written were dropped because we cleared it
after re-running the effect that made a write.
- solution: clear the tracked signal before re-executing the effects
- reading signal A while also writing signal A makes an infinite loop
- current solution: use toRaw in order to not track the read
- possible better solution to explore: do not track read if there is a write in a effect.
## website issue
- a rpc request was made on onWillStart, onWillStart was tracking reads. (see WebsiteBuilderClientAction)
- The read subsequently made a write, that re-triggered the onWillStart.
- A similar situation happened with onWillUpdateProps (see Transition)
- solution: prevent tracking reads in onWillStart and onWillUpdateProps
# future
- worker for computation?
- cap'n web
+24
View File
@@ -1,4 +1,28 @@
export type ExecutionContext = {
onReadAtom: (atom: Atom) => void;
unsubcribe?: (scheduledContexts: Set<ExecutionContext>) => void;
update?: Function;
atoms?: Set<Atom>;
meta?: any;
// getParent: () => ExecutionContext | undefined;
// getChildren: () => ExecutionContext[];
// schedule: () => void;
};
export type customDirectives = Record<
string,
(node: Element, value: string, modifier: string[]) => void
>;
export type Atom = {
executionContexts: Set<ExecutionContext>;
dependents: Set<DerivedAtom>;
getValue: () => any;
};
export type OldValue = any;
export type DerivedAtom = Atom & {
dependencies: Map<Atom, OldValue>;
computed: boolean;
};
+14 -5
View File
@@ -254,7 +254,16 @@ class CodeTarget {
}
}
const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"];
const TRANSLATABLE_ATTRS = [
"alt",
"aria-label",
"aria-placeholder",
"aria-roledescription",
"aria-valuetext",
"label",
"placeholder",
"title",
];
const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
export class CodeGenerator {
@@ -986,7 +995,7 @@ export class CodeGenerator {
const isNewBlock = !block || forceNewBlock;
let codeIdx = this.target.code.length;
if (isNewBlock) {
const n = ast.content.filter((c) => c.type !== ASTType.TSet).length;
const n = ast.content.filter((c) => !c.hasNoRepresentation).length;
let result: string | null = null;
if (n <= 1) {
for (let child of ast.content) {
@@ -1000,15 +1009,15 @@ export class CodeGenerator {
let index = 0;
for (let i = 0, l = ast.content.length; i < l; i++) {
const child = ast.content[i];
const isTSet = child.type === ASTType.TSet;
const forceNewBlock = !child.hasNoRepresentation;
const subCtx = createContext(ctx, {
block,
index,
forceNewBlock: !isTSet,
forceNewBlock,
isLast: ctx.isLast && i === l - 1,
});
this.compileAST(child, subCtx);
if (!isTSet) {
if (forceNewBlock) {
index++;
}
}
+95 -40
View File
@@ -31,12 +31,17 @@ export const enum ASTType {
TPortal,
}
export interface ASTText {
export interface BaseAST {
type: ASTType;
hasNoRepresentation?: true;
}
export interface ASTText extends BaseAST {
type: ASTType.Text;
value: string;
}
export interface ASTComment {
export interface ASTComment extends BaseAST {
type: ASTType.Comment;
value: string;
}
@@ -52,7 +57,7 @@ interface TModelInfo {
specialInitTargetAttr: string | null;
}
export interface ASTDomNode {
export interface ASTDomNode extends BaseAST {
type: ASTType.DomNode;
tag: string;
content: AST[];
@@ -65,24 +70,24 @@ export interface ASTDomNode {
ns: string | null;
}
export interface ASTMulti {
export interface ASTMulti extends BaseAST {
type: ASTType.Multi;
content: AST[];
}
export interface ASTTEsc {
export interface ASTTEsc extends BaseAST {
type: ASTType.TEsc;
expr: string;
defaultValue: string;
}
export interface ASTTOut {
export interface ASTTOut extends BaseAST {
type: ASTType.TOut;
expr: string;
body: AST[] | null;
}
export interface ASTTif {
export interface ASTTif extends BaseAST {
type: ASTType.TIf;
condition: string;
content: AST;
@@ -90,15 +95,16 @@ export interface ASTTif {
tElse: AST | null;
}
export interface ASTTSet {
export interface ASTTSet extends BaseAST {
type: ASTType.TSet;
name: string;
value: string | null; // value defined in attribute
defaultValue: string | null; // value defined in body, if text
body: AST[] | null; // content of body if not text
hasNoRepresentation: true;
}
export interface ASTTForEach {
export interface ASTTForEach extends BaseAST {
type: ASTType.TForEach;
collection: string;
elem: string;
@@ -111,13 +117,13 @@ export interface ASTTForEach {
key: string | null;
}
export interface ASTTKey {
export interface ASTTKey extends BaseAST {
type: ASTType.TKey;
expr: string;
content: AST;
}
export interface ASTTCall {
export interface ASTTCall extends BaseAST {
type: ASTType.TCall;
name: string;
body: AST[] | null;
@@ -132,7 +138,7 @@ interface SlotDefinition {
attrsTranslationCtx: Attrs | null;
}
export interface ASTComponent {
export interface ASTComponent extends BaseAST {
type: ASTType.TComponent;
name: string;
isDynamic: boolean;
@@ -143,7 +149,7 @@ export interface ASTComponent {
slots: { [name: string]: SlotDefinition } | null;
}
export interface ASTSlot {
export interface ASTSlot extends BaseAST {
type: ASTType.TSlot;
name: string;
attrs: Attrs | null;
@@ -152,34 +158,34 @@ export interface ASTSlot {
defaultContent: AST | null;
}
export interface ASTTCallBlock {
export interface ASTTCallBlock extends BaseAST {
type: ASTType.TCallBlock;
name: string;
}
export interface ASTDebug {
export interface ASTDebug extends BaseAST {
type: ASTType.TDebug;
content: AST | null;
}
export interface ASTLog {
export interface ASTLog extends BaseAST {
type: ASTType.TLog;
expr: string;
content: AST | null;
}
export interface ASTTranslation {
export interface ASTTranslation extends BaseAST {
type: ASTType.TTranslation;
content: AST | null;
}
export interface ASTTranslationContext {
export interface ASTTranslationContext extends BaseAST {
type: ASTType.TTranslationContext;
content: AST | null;
translationCtx: string;
}
export interface ASTTPortal {
export interface ASTTPortal extends BaseAST {
type: ASTType.TPortal;
target: string;
content: AST;
@@ -253,11 +259,11 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null {
parseTPortal(node, ctx) ||
parseTCall(node, ctx) ||
parseTCallBlock(node, ctx) ||
parseTEscNode(node, ctx) ||
parseTOutNode(node, ctx) ||
parseTKey(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTKey(node, ctx) ||
parseTEscNode(node, ctx) ||
parseTOutNode(node, ctx) ||
parseTSlot(node, ctx) ||
parseComponent(node, ctx) ||
parseDOMNode(node, ctx) ||
@@ -334,20 +340,30 @@ function parseTCustom(node: Element, ctx: ParsingContext): AST | null {
function parseTDebugLog(node: Element, ctx: ParsingContext): AST | null {
if (node.hasAttribute("t-debug")) {
node.removeAttribute("t-debug");
return {
const content = parseNode(node, ctx);
const ast: ASTDebug = {
type: ASTType.TDebug,
content: parseNode(node, ctx),
content,
};
if (content?.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
if (node.hasAttribute("t-log")) {
const expr = node.getAttribute("t-log")!;
node.removeAttribute("t-log");
return {
const content = parseNode(node, ctx);
const ast: ASTLog = {
type: ASTType.TLog,
expr,
content: parseNode(node, ctx),
content,
};
if (content?.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
return null;
}
@@ -598,11 +614,19 @@ function parseTKey(node: Element, ctx: ParsingContext): AST | null {
}
const key = node.getAttribute("t-key")!;
node.removeAttribute("t-key");
const body = parseNode(node, ctx);
if (!body) {
const content = parseNode(node, ctx);
if (!content) {
return null;
}
return { type: ASTType.TKey, expr: key, content: body };
const ast: ASTTKey = {
type: ASTType.TKey,
expr: key,
content,
};
if (content.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
// -----------------------------------------------------------------------------
@@ -724,7 +748,7 @@ function parseTSetNode(node: Element, ctx: ParsingContext): AST | null {
if (node.textContent !== node.innerHTML) {
body = parseChildren(node, ctx);
}
return { type: ASTType.TSet, name, value, defaultValue, body };
return { type: ASTType.TSet, name, value, defaultValue, body, hasNoRepresentation: true };
}
// -----------------------------------------------------------------------------
@@ -916,32 +940,55 @@ function parseTSlot(node: Element, ctx: ParsingContext): AST | null {
// Translation
// -----------------------------------------------------------------------------
function wrapInTTranslationAST(r: AST | null) {
const ast: ASTTranslation = { type: ASTType.TTranslation, content: r };
if (r?.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
if (node.getAttribute("t-translation") !== "off") {
return null;
}
node.removeAttribute("t-translation");
return {
type: ASTType.TTranslation,
content: parseNode(node, ctx),
};
const result = parseNode(node, ctx);
if (result?.type === ASTType.Multi) {
const children = result.content.map(wrapInTTranslationAST);
return makeASTMulti(children);
}
return wrapInTTranslationAST(result);
}
// -----------------------------------------------------------------------------
// Translation Context
// -----------------------------------------------------------------------------
function wrapInTTranslationContextAST(r: AST | null, translationCtx: string) {
const ast: ASTTranslationContext = {
type: ASTType.TTranslationContext,
content: r,
translationCtx,
};
if (r?.hasNoRepresentation) {
ast.hasNoRepresentation = true;
}
return ast;
}
function parseTTranslationContext(node: Element, ctx: ParsingContext): AST | null {
const translationCtx = node.getAttribute("t-translation-context");
if (!translationCtx) {
return null;
}
node.removeAttribute("t-translation-context");
return {
type: ASTType.TTranslationContext,
content: parseNode(node, ctx),
translationCtx,
};
const result = parseNode(node, ctx);
if (result?.type === ASTType.Multi) {
const children = result.content.map((c) => wrapInTTranslationContextAST(c, translationCtx));
return makeASTMulti(children);
}
return wrapInTTranslationContextAST(result, translationCtx);
}
// -----------------------------------------------------------------------------
@@ -990,6 +1037,14 @@ function parseChildren(node: Element, ctx: ParsingContext): AST[] {
return children;
}
function makeASTMulti(children: AST[]) {
const ast: ASTMulti = { type: ASTType.Multi, content: children };
if (children.every((c) => c.hasNoRepresentation)) {
ast.hasNoRepresentation = true;
}
return ast;
}
/**
* Parse all the child nodes of a given node and return an ast if possible.
* In the case there are multiple children, they are wrapped in a astmulti.
@@ -1002,7 +1057,7 @@ function parseChildNodes(node: Element, ctx: ParsingContext): AST | null {
case 1:
return children[0];
default:
return { type: ASTType.Multi, content: children };
return makeASTMulti(children);
}
}
+46
View File
@@ -0,0 +1,46 @@
export type TaskContext = { isCancelled: boolean; cancel: () => void; meta: Record<string, any> };
export const taskContextStack: TaskContext[] = [];
export function getTaskContext() {
return taskContextStack[taskContextStack.length - 1];
}
export function makeTaskContext(): TaskContext {
let isCancelled = false;
return {
get isCancelled() {
return isCancelled;
},
cancel() {
isCancelled = true;
},
meta: {},
};
}
export function useTaskContext(ctx?: TaskContext) {
ctx ??= makeTaskContext();
taskContextStack.push(ctx);
return {
ctx,
cleanup: () => {
taskContextStack.pop();
},
};
}
export function pushTaskContext(context: TaskContext) {
taskContextStack.push(context);
}
export function popTaskContext() {
taskContextStack.pop();
}
export function taskEffect(fn: Function) {
const { ctx, cleanup } = useTaskContext();
fn();
cleanup();
return ctx;
}
+46 -33
View File
@@ -1,12 +1,13 @@
import { OwlError } from "../common/owl_error";
import { Atom, ExecutionContext } from "../common/types";
import type { App, Env } from "./app";
import { BDom, VNode } from "./blockdom";
import { makeTaskContext, TaskContext } from "./cancellableContext";
import { Component, ComponentConstructor, Props } from "./component";
import { fibersInError } from "./error_handling";
import { OwlError } from "../common/owl_error";
import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers";
import { clearReactivesForCallback, getSubscriptions, reactive, targets } from "./reactivity";
import { addAtomToContext, reactive, targets, withoutReactivity } from "./reactivity";
import { STATUS } from "./status";
import { batched, Callback } from "./utils";
let currentNode: ComponentNode | null = null;
@@ -42,7 +43,7 @@ function applyDefaultProps<P extends object>(props: P, defaultProps: Partial<P>)
// Integration with reactivity system (useState)
// -----------------------------------------------------------------------------
const batchedRenderFunctions = new WeakMap<ComponentNode, Callback>();
// const batchedRenderFunctions = new WeakMap<ComponentNode, Callback>();
/**
* Creates a reactive object that will be observed by the current component.
* Reading data from the returned object (eg during rendering) will cause the
@@ -54,15 +55,7 @@ const batchedRenderFunctions = new WeakMap<ComponentNode, Callback>();
* @see reactive
*/
export function useState<T extends object>(state: T): T {
const node = getCurrent();
let render = batchedRenderFunctions.get(node)!;
if (!render) {
render = batched(node.render.bind(node, false));
batchedRenderFunctions.set(node, render);
// manual implementation of onWillDestroy to break cyclic dependency
node.willDestroy.push(clearReactivesForCallback.bind(null, render));
}
return reactive(state, render);
return reactive(state);
}
// -----------------------------------------------------------------------------
@@ -96,6 +89,8 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
willPatch: LifecycleHook[] = [];
patched: LifecycleHook[] = [];
willDestroy: LifecycleHook[] = [];
taskContext: TaskContext;
executionContext: ExecutionContext;
constructor(
C: ComponentConstructor<P, E>,
@@ -109,6 +104,15 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
this.parent = parent;
this.props = props;
this.parentKey = parentKey;
this.taskContext = makeTaskContext();
this.executionContext = {
meta: this,
update: () => {
this.render(false);
},
onReadAtom: (atom: Atom) => addAtomToContext(atom, this.executionContext),
atoms: new Set<Atom>(),
};
const defaultProps = C.defaultProps;
props = Object.assign({}, props);
if (defaultProps) {
@@ -116,16 +120,18 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
}
const env = (parent && parent.childEnv) || app.env;
this.childEnv = env;
for (const key in props) {
const prop = props[key];
if (prop && typeof prop === "object" && targets.has(prop)) {
props[key] = useState(prop);
}
}
// for (const key in props) {
// const prop = props[key];
// if (prop && typeof prop === "object" && targets.has(prop)) {
// props[key] = useState(prop);
// }
// }
this.component = new C(props, env, this);
const ctx = Object.assign(Object.create(this.component), { this: this.component });
this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
this.component.setup();
withoutReactivity(() => {
this.component.setup();
});
currentNode = null;
}
@@ -142,7 +148,11 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
}
const component = this.component;
try {
await Promise.all(this.willStart.map((f) => f.call(component)));
let prom: Promise<any[]>;
withoutReactivity(() => {
prom = Promise.all(this.willStart.map((f) => f.call(component)));
});
await prom!;
} catch (e) {
this.app.handleError({ node: this, error: e });
return;
@@ -258,15 +268,18 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
}
currentNode = this;
for (const key in props) {
const prop = props[key];
if (prop && typeof prop === "object" && targets.has(prop)) {
props[key] = useState(prop);
}
}
// for (const key in props) {
// const prop = props[key];
// if (prop && typeof prop === "object" && targets.has(prop)) {
// props[key] = useState(prop);
// }
// }
currentNode = null;
const prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props)));
await prom;
let prom: Promise<any[]>;
withoutReactivity(() => {
prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props)));
});
await prom!;
if (fiber !== this.fiber) {
return;
}
@@ -384,8 +397,8 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
return this.component.constructor.name;
}
get subscriptions(): ReturnType<typeof getSubscriptions> {
const render = batchedRenderFunctions.get(this);
return render ? getSubscriptions(render) : [];
}
// get subscriptions(): ReturnType<typeof getSubscriptions> {
// const render = batchedRenderFunctions.get(this);
// return render ? getSubscriptions(render) : [];
// }
}
+26
View File
@@ -0,0 +1,26 @@
import { ExecutionContext } from "../common/types";
export const executionContexts: ExecutionContext[] = [];
(window as any).executionContexts = executionContexts;
// export const scheduledContexts: Set<ExecutionContext> = new Set();
export function getExecutionContext() {
return executionContexts[executionContexts.length - 1];
}
export function pushExecutionContext(context: ExecutionContext) {
executionContexts.push(context);
}
export function popExecutionContext() {
executionContexts.pop();
}
// export function makeExecutionContext({ update, meta }: { update: () => void; meta?: any }) {
// const executionContext: ExecutionContext = {
// update,
// atoms: new Set(),
// meta: meta || {},
// };
// return executionContext;
// }
+6
View File
@@ -3,6 +3,8 @@ import type { ComponentNode } from "./component_node";
import { fibersInError } from "./error_handling";
import { OwlError } from "../common/owl_error";
import { STATUS } from "./status";
import { popTaskContext, pushTaskContext } from "./cancellableContext";
import { popExecutionContext, pushExecutionContext } from "./executionContext";
export function makeChildFiber(node: ComponentNode, parent: Fiber): Fiber {
let current = node.fiber;
@@ -133,12 +135,16 @@ export class Fiber {
const node = this.node;
const root = this.root;
if (root) {
pushTaskContext(node.taskContext);
pushExecutionContext(node.executionContext);
try {
(this.bdom as any) = true;
this.bdom = node.renderFn();
} catch (e) {
node.app.handleError({ node, error: e });
}
popExecutionContext();
popTaskContext();
root.setCounter(root.counter - 1);
}
}
+27 -5
View File
@@ -1,5 +1,6 @@
import type { Env } from "./app";
import { getCurrent } from "./component_node";
import { popExecutionContext, pushExecutionContext } from "./executionContext";
import { onMounted, onPatched, onWillUnmount } from "./lifecycle_hooks";
import { inOwnerDocument } from "./utils";
@@ -86,22 +87,43 @@ export function useEffect<T extends unknown[]>(
effect: Effect<T>,
computeDependencies: () => [...T] = () => [NaN] as never
) {
const context = getCurrent().component.__owl__.executionContext;
let cleanup: (() => void) | void;
let dependencies: T;
const runEffect = () => {
pushExecutionContext(context);
try {
cleanup = effect(...dependencies);
} finally {
popExecutionContext();
}
};
const computeDependenciesWithContext = () => {
pushExecutionContext(context);
let r: any;
try {
r = computeDependencies();
} finally {
popExecutionContext();
}
return r;
};
onMounted(() => {
dependencies = computeDependencies();
cleanup = effect(...dependencies);
dependencies = computeDependenciesWithContext();
runEffect();
});
onPatched(() => {
const newDeps = computeDependencies();
const shouldReapply = newDeps.some((val, i) => val !== dependencies[i]);
const newDeps = computeDependenciesWithContext();
const shouldReapply = newDeps.some((val: any, i: number) => val !== dependencies[i]);
if (shouldReapply) {
dependencies = newDeps;
if (cleanup) {
cleanup();
}
cleanup = effect(...dependencies);
runEffect();
}
});
+2 -2
View File
@@ -39,9 +39,9 @@ export { Component } from "./component";
export type { ComponentConstructor } from "./component";
export { useComponent, useState } from "./component_node";
export { status } from "./status";
export { reactive, markRaw, toRaw } from "./reactivity";
export { reactive, markRaw, toRaw, effect, withoutReactivity } from "./reactivity";
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
export { batched, EventBus, whenReady, loadFile, markup } from "./utils";
export { batched, EventBus, htmlEscape, whenReady, loadFile, markup } from "./utils";
export {
onWillStart,
onMounted,
+274 -145
View File
@@ -1,13 +1,9 @@
import type { Callback } from "./utils";
import { OwlError } from "../common/owl_error";
import { ExecutionContext, Atom, DerivedAtom, OldValue } from "../common/types";
import { getExecutionContext, popExecutionContext, pushExecutionContext } from "./executionContext";
// Special key to subscribe to, to be notified of key creation/deletion
const KEYCHANGES = Symbol("Key changes");
// Used to specify the absence of a callback, can be used as WeakMap key but
// should only be used as a sentinel value and never called.
const NO_CALLBACK = () => {
throw new Error("Called NO_CALLBACK. Owl is broken, please report this to the maintainers.");
};
// The following types only exist to signify places where objects are expected
// to be reactive or not, they provide no type checking benefit over "object"
@@ -55,8 +51,8 @@ function canBeMadeReactive(value: any): boolean {
* @param value the value make reactive
* @returns a reactive for the given object when possible, the original otherwise
*/
function possiblyReactive(val: any, cb: Callback) {
return canBeMadeReactive(val) ? reactive(val, cb) : val;
function possiblyReactive(val: any) {
return canBeMadeReactive(val) ? reactive(val) : val;
}
const skipped = new WeakSet<Target>();
@@ -81,7 +77,37 @@ export function toRaw<T extends Target, U extends Reactive<T>>(value: U | T): T
return targets.has(value) ? (targets.get(value) as T) : value;
}
const targetToKeysToCallbacks = new WeakMap<Target, Map<PropertyKey, Set<Callback>>>();
const targetToKeysToAtomItem = new WeakMap<Target, Map<PropertyKey, Atom>>();
const scheduledAtoms = new Set<Atom>();
function makeAtom(getValue: () => any): Atom {
const atom: Atom = {
executionContexts: new Set<ExecutionContext>(),
dependents: new Set<Atom>(),
// getValue,
};
return atom;
}
function getTargetKeyAtom(target: Target, key: PropertyKey): Atom {
let keyToAtomItem: Map<PropertyKey, Atom> = targetToKeysToAtomItem.get(target)!;
if (!keyToAtomItem) {
keyToAtomItem = new Map();
targetToKeysToAtomItem.set(target, keyToAtomItem);
}
let atom = keyToAtomItem.get(key)!;
if (!atom) {
atom = makeAtom(() => Reflect.get(target, key));
keyToAtomItem.set(key, atom);
}
return atom;
}
export function addAtomToContext(atom: Atom, executionContext: ExecutionContext) {
executionContext.atoms.add(atom);
atom.executionContexts.add(executionContext);
}
/**
* Observes a given key on a target with an callback. The callback will be
* called when the given key changes on the target.
@@ -91,23 +117,73 @@ const targetToKeysToCallbacks = new WeakMap<Target, Map<PropertyKey, Set<Callbac
* or deletion)
* @param callback the function to call when the key changes
*/
function observeTargetKey(target: Target, key: PropertyKey, callback: Callback): void {
if (callback === NO_CALLBACK) {
return;
}
if (!targetToKeysToCallbacks.get(target)) {
targetToKeysToCallbacks.set(target, new Map());
}
const keyToCallbacks = targetToKeysToCallbacks.get(target)!;
if (!keyToCallbacks.get(key)) {
keyToCallbacks.set(key, new Set());
}
keyToCallbacks.get(key)!.add(callback);
if (!callbacksToTargets.has(callback)) {
callbacksToTargets.set(callback, new Set());
}
callbacksToTargets.get(callback)!.add(target);
function onReadTargetKey(target: Target, key: PropertyKey, receiver: any): void {
const executionContext = getExecutionContext();
executionContext?.onReadAtom(getTargetKeyAtom(target, key));
}
let scheduled = false;
function scheduleAtom(atom: Atom) {
scheduledAtoms.add(atom);
// batched(processAtoms)();
if (scheduled) return;
scheduled = true;
Promise.resolve().then(() => {
scheduled = false;
processAtoms();
});
}
function processDerivedAtoms() {
const processedAtoms = new Set<Atom>();
for (const atom of scheduledAtoms) {
for (const dep of atom.dependents) {
if (processedAtoms.has(dep)) continue;
dep.computed = false;
processedAtoms.add(dep);
}
}
}
function processAtoms() {
processDerivedAtoms();
const scheduledContexts = new Set(
[...scheduledAtoms.values()].map((s) => [...s.executionContexts]).flat()
);
// schedule before context.update in case there is write operations during update
// todo: add a test in case there is write operations during update the test
// will break is scheduledAtoms.clear(); is called after context.update();
// that writes
scheduledAtoms.clear();
for (const ctx of [...scheduledContexts]) {
removeAtomsFromContext(ctx);
// custom unsubscribe depending on the context.
// scheduledContexts might be updated while we're iterating over it.
ctx.unsubcribe?.(scheduledContexts);
}
for (const context of scheduledContexts) {
pushExecutionContext(context);
try {
context.update?.();
} finally {
popExecutionContext();
}
}
}
/**
* Notify Reactives that are observing a given target that a key has changed on
}
});
};
for (const context of executionContexts) {
context.update();
}
}
/**
* Notify Reactives that are observing a given target that a key has changed on
* the target.
@@ -117,66 +193,21 @@ function observeTargetKey(target: Target, key: PropertyKey, callback: Callback):
* @param key the key that changed (or Symbol `KEYCHANGES` if a key was created
* or deleted)
*/
function notifyReactives(target: Target, key: PropertyKey): void {
const keyToCallbacks = targetToKeysToCallbacks.get(target);
if (!keyToCallbacks) {
function onWriteTargetKey(target: Target, key: PropertyKey): void {
const keyToAtomItem = targetToKeysToAtomItem.get(target)!;
if (!keyToAtomItem) {
return;
}
const callbacks = keyToCallbacks.get(key);
if (!callbacks) {
const atom = keyToAtomItem.get(key);
if (!atom) {
return;
}
// Loop on copy because clearReactivesForCallback will modify the set in place
for (const callback of [...callbacks]) {
clearReactivesForCallback(callback);
callback();
}
scheduleAtom(atom);
}
const callbacksToTargets = new WeakMap<Callback, Set<Target>>();
/**
* Clears all subscriptions of the Reactives associated with a given callback.
*
* @param callback the callback for which the reactives need to be cleared
*/
export function clearReactivesForCallback(callback: Callback): void {
const targetsToClear = callbacksToTargets.get(callback);
if (!targetsToClear) {
return;
}
for (const target of targetsToClear) {
const observedKeys = targetToKeysToCallbacks.get(target);
if (!observedKeys) {
continue;
}
for (const [key, callbacks] of observedKeys.entries()) {
callbacks.delete(callback);
if (!callbacks.size) {
observedKeys.delete(key);
}
}
}
targetsToClear.clear();
}
export function getSubscriptions(callback: Callback) {
const targets = callbacksToTargets.get(callback) || [];
return [...targets].map((target) => {
const keysToCallbacks = targetToKeysToCallbacks.get(target);
let keys = [];
if (keysToCallbacks) {
for (const [key, cbs] of keysToCallbacks) {
if (cbs.has(callback)) {
keys.push(key);
}
}
}
return { target, keys };
});
}
// Maps reactive objects to the underlying target
export const targets = new WeakMap<Reactive<Target>, Target>();
const reactiveCache = new WeakMap<Target, WeakMap<Callback, Reactive<Target>>>();
const reactiveCache = new WeakMap<Target, Reactive<Target>>();
/**
* Creates a reactive proxy for an object. Reading data on the reactive object
* subscribes to changes to the data. Writing data on the object will cause the
@@ -204,7 +235,7 @@ const reactiveCache = new WeakMap<Target, WeakMap<Callback, Reactive<Target>>>()
* reactive has changed
* @returns a proxy that tracks changes to it
*/
export function reactive<T extends Target>(target: T, callback: Callback = NO_CALLBACK): T {
export function reactive<T extends Target>(target: T): T {
if (!canBeMadeReactive(target)) {
throw new OwlError(`Cannot make the given value reactive`);
}
@@ -213,30 +244,130 @@ export function reactive<T extends Target>(target: T, callback: Callback = NO_CA
}
if (targets.has(target)) {
// target is reactive, create a reactive on the underlying object instead
return reactive(targets.get(target) as T, callback);
// return reactive(targets.get(target) as T);
return target;
}
if (!reactiveCache.has(target)) {
reactiveCache.set(target, new WeakMap());
}
const reactivesForTarget = reactiveCache.get(target)!;
if (!reactivesForTarget.has(callback)) {
const targetRawType = rawType(target);
const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
? collectionsProxyHandler(target as Collection, callback, targetRawType as CollectionRawType)
: basicProxyHandler<T>(callback);
const proxy = new Proxy(target, handler as ProxyHandler<T>) as Reactive<T>;
reactivesForTarget.set(callback, proxy);
targets.set(proxy, target);
}
return reactivesForTarget.get(callback) as Reactive<T>;
const reactive = reactiveCache.get(target)!;
if (reactive) return reactive as T;
const targetRawType = rawType(target);
const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
? collectionsProxyHandler(target as Collection, targetRawType as CollectionRawType)
: basicProxyHandler<T>();
const proxy = new Proxy(target, handler as ProxyHandler<T>) as Reactive<T>;
reactiveCache.set(target, proxy);
targets.set(proxy, target);
return proxy;
}
function removeAtomsFromContext(executionContext: ExecutionContext) {
for (const sig of executionContext.atoms) {
sig.executionContexts.delete(executionContext);
}
executionContext.atoms.clear();
}
/**
* Unsubscribe an execution context and all its children from all atoms
* they are subscribed to.
*
* @param parentExecutionContext the context to unsubscribe
*/
function unsubscribeChildEffect(
parentExecutionContext: ExecutionContext,
scheduledContexts: Set<ExecutionContext>
) {
// executionContext.update = () => {};
for (const children of parentExecutionContext.meta.children) {
children.meta.parent = undefined;
removeAtomsFromContext(children);
scheduledContexts.delete(children);
unsubscribeChildEffect(children, scheduledContexts);
}
parentExecutionContext.meta.children.length = 0;
}
export function withoutReactivity<T extends (...args: any[]) => any>(fn: T): ReturnType<T> {
pushExecutionContext(undefined!);
let r: ReturnType<T>;
try {
r = fn();
} finally {
popExecutionContext();
}
return r;
}
export function effect(fn: Function) {
let parent = getExecutionContext();
// todo: is it useful?
if (parent && !parent?.meta.children) {
parent = undefined!;
}
const executionContext: ExecutionContext = {
unsubcribe: (scheduledContexts: Set<ExecutionContext>) => {
unsubscribeChildEffect(executionContext, scheduledContexts);
},
update: fn,
onReadAtom: (atom: Atom) => addAtomToContext(atom, executionContext),
atoms: new Set(),
meta: {
parent: parent,
children: [],
},
};
if (parent) {
// todo: is it useful?
parent.meta.children?.push?.(executionContext);
}
pushExecutionContext(executionContext);
try {
fn();
} finally {
popExecutionContext();
}
}
export function derived(fn: Function) {
let lastValue: any;
const derivedAtom: DerivedAtom = {
executionContexts: new Set<ExecutionContext>(),
dependents: new Set<Atom>(),
dependencies: new Map<Atom, OldValue>(),
getValue: () => lastValue,
computed: false,
};
return () => {
const executionContext = getExecutionContext();
executionContext?.onReadAtom(derivedAtom);
if (derivedAtom.computed) return lastValue;
const derivedExecutionContext: ExecutionContext = {
onReadAtom: (atom: Atom) => {
atom.dependents.add(derivedAtom);
// derivedAtom.executionContexts.add(executionContext);
},
};
pushExecutionContext(derivedExecutionContext);
try {
lastValue = fn();
} finally {
popExecutionContext();
}
derivedAtom.computed = true;
return lastValue;
};
}
/**
* Creates a basic proxy handler for regular objects and arrays.
*
* @param callback @see reactive
* @returns a proxy handler object
*/
function basicProxyHandler<T extends Target>(callback: Callback): ProxyHandler<T> {
function basicProxyHandler<T extends Target>(): ProxyHandler<T> {
return {
get(target, key, receiver) {
// non-writable non-configurable properties cannot be made reactive
@@ -244,15 +375,15 @@ function basicProxyHandler<T extends Target>(callback: Callback): ProxyHandler<T
if (desc && !desc.writable && !desc.configurable) {
return Reflect.get(target, key, receiver);
}
observeTargetKey(target, key, callback);
return possiblyReactive(Reflect.get(target, key, receiver), callback);
onReadTargetKey(target, key);
return possiblyReactive(Reflect.get(target, key, receiver));
},
set(target, key, value, receiver) {
const hadKey = objectHasOwnProperty.call(target, key);
const originalValue = Reflect.get(target, key, receiver);
const ret = Reflect.set(target, key, toRaw(value), receiver);
if (!hadKey && objectHasOwnProperty.call(target, key)) {
notifyReactives(target, KEYCHANGES);
onWriteTargetKey(target, KEYCHANGES);
}
// While Array length may trigger the set trap, it's not actually set by this
// method but is updated behind the scenes, and the trap is not called with the
@@ -261,26 +392,26 @@ function basicProxyHandler<T extends Target>(callback: Callback): ProxyHandler<T
originalValue !== Reflect.get(target, key, receiver) ||
(key === "length" && Array.isArray(target))
) {
notifyReactives(target, key);
onWriteTargetKey(target, key);
}
return ret;
},
deleteProperty(target, key) {
const ret = Reflect.deleteProperty(target, key);
// TODO: only notify when something was actually deleted
notifyReactives(target, KEYCHANGES);
notifyReactives(target, key);
onWriteTargetKey(target, KEYCHANGES);
onWriteTargetKey(target, key);
return ret;
},
ownKeys(target) {
observeTargetKey(target, KEYCHANGES, callback);
onReadTargetKey(target, KEYCHANGES);
return Reflect.ownKeys(target);
},
has(target, key) {
// TODO: this observes all key changes instead of only the presence of the argument key
// observing the key itself would observe value changes instead of presence changes
// so we may need a finer grained system to distinguish observing value vs presence.
observeTargetKey(target, KEYCHANGES, callback);
onReadTargetKey(target, KEYCHANGES);
return Reflect.has(target, key);
},
} as ProxyHandler<T>;
@@ -293,11 +424,11 @@ function basicProxyHandler<T extends Target>(callback: Callback): ProxyHandler<T
* @param target @see reactive
* @param callback @see reactive
*/
function makeKeyObserver(methodName: "has" | "get", target: any, callback: Callback) {
function makeKeyObserver(methodName: "has" | "get", target: any) {
return (key: any) => {
key = toRaw(key);
observeTargetKey(target, key, callback);
return possiblyReactive(target[methodName](key), callback);
onReadTargetKey(target, key);
return possiblyReactive(target[methodName](key));
};
}
/**
@@ -310,16 +441,15 @@ function makeKeyObserver(methodName: "has" | "get", target: any, callback: Callb
*/
function makeIteratorObserver(
methodName: "keys" | "values" | "entries" | typeof Symbol.iterator,
target: any,
callback: Callback
target: any
) {
return function* () {
observeTargetKey(target, KEYCHANGES, callback);
onReadTargetKey(target, KEYCHANGES);
const keys = target.keys();
for (const item of target[methodName]()) {
const key = keys.next().value;
observeTargetKey(target, key, callback);
yield possiblyReactive(item, callback);
onReadTargetKey(target, key);
yield possiblyReactive(item);
}
};
}
@@ -331,16 +461,16 @@ function makeIteratorObserver(
* @param target @see reactive
* @param callback @see reactive
*/
function makeForEachObserver(target: any, callback: Callback) {
function makeForEachObserver(target: any) {
return function forEach(forEachCb: (val: any, key: any, target: any) => void, thisArg: any) {
observeTargetKey(target, KEYCHANGES, callback);
onReadTargetKey(target, KEYCHANGES);
target.forEach(function (val: any, key: any, targetObj: any) {
observeTargetKey(target, key, callback);
onReadTargetKey(target, key);
forEachCb.call(
thisArg,
possiblyReactive(val, callback),
possiblyReactive(key, callback),
possiblyReactive(targetObj, callback)
possiblyReactive(val),
possiblyReactive(key),
possiblyReactive(targetObj)
);
}, thisArg);
};
@@ -367,10 +497,10 @@ function delegateAndNotify(
const ret = target[setterName](key, value);
const hasKey = target.has(key);
if (hadKey !== hasKey) {
notifyReactives(target, KEYCHANGES);
onWriteTargetKey(target, KEYCHANGES);
}
if (originalValue !== target[getterName](key)) {
notifyReactives(target, key);
onWriteTargetKey(target, key);
}
return ret;
};
@@ -385,9 +515,9 @@ function makeClearNotifier(target: Map<any, any> | Set<any>) {
return () => {
const allKeys = [...target.keys()];
target.clear();
notifyReactives(target, KEYCHANGES);
onWriteTargetKey(target, KEYCHANGES);
for (const key of allKeys) {
notifyReactives(target, key);
onWriteTargetKey(target, key);
}
};
}
@@ -399,40 +529,40 @@ function makeClearNotifier(target: Map<any, any> | Set<any>) {
* reactives that the key which is being added or deleted has been modified.
*/
const rawTypeToFuncHandlers = {
Set: (target: any, callback: Callback) => ({
has: makeKeyObserver("has", target, callback),
Set: (target: any) => ({
has: makeKeyObserver("has", target),
add: delegateAndNotify("add", "has", target),
delete: delegateAndNotify("delete", "has", target),
keys: makeIteratorObserver("keys", target, callback),
values: makeIteratorObserver("values", target, callback),
entries: makeIteratorObserver("entries", target, callback),
[Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback),
forEach: makeForEachObserver(target, callback),
keys: makeIteratorObserver("keys", target),
values: makeIteratorObserver("values", target),
entries: makeIteratorObserver("entries", target),
[Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target),
forEach: makeForEachObserver(target),
clear: makeClearNotifier(target),
get size() {
observeTargetKey(target, KEYCHANGES, callback);
onReadTargetKey(target, KEYCHANGES);
return target.size;
},
}),
Map: (target: any, callback: Callback) => ({
has: makeKeyObserver("has", target, callback),
get: makeKeyObserver("get", target, callback),
Map: (target: any) => ({
has: makeKeyObserver("has", target),
get: makeKeyObserver("get", target),
set: delegateAndNotify("set", "get", target),
delete: delegateAndNotify("delete", "has", target),
keys: makeIteratorObserver("keys", target, callback),
values: makeIteratorObserver("values", target, callback),
entries: makeIteratorObserver("entries", target, callback),
[Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback),
forEach: makeForEachObserver(target, callback),
keys: makeIteratorObserver("keys", target),
values: makeIteratorObserver("values", target),
entries: makeIteratorObserver("entries", target),
[Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target),
forEach: makeForEachObserver(target),
clear: makeClearNotifier(target),
get size() {
observeTargetKey(target, KEYCHANGES, callback);
onReadTargetKey(target, KEYCHANGES);
return target.size;
},
}),
WeakMap: (target: any, callback: Callback) => ({
has: makeKeyObserver("has", target, callback),
get: makeKeyObserver("get", target, callback),
WeakMap: (target: any) => ({
has: makeKeyObserver("has", target),
get: makeKeyObserver("get", target),
set: delegateAndNotify("set", "get", target),
delete: delegateAndNotify("delete", "has", target),
}),
@@ -446,20 +576,19 @@ const rawTypeToFuncHandlers = {
*/
function collectionsProxyHandler<T extends Collection>(
target: T,
callback: Callback,
targetRawType: CollectionRawType
): ProxyHandler<T> {
// TODO: if performance is an issue we can create the special handlers lazily when each
// property is read.
const specialHandlers = rawTypeToFuncHandlers[targetRawType](target, callback);
return Object.assign(basicProxyHandler(callback), {
const specialHandlers = rawTypeToFuncHandlers[targetRawType](target);
return Object.assign(basicProxyHandler(), {
// FIXME: probably broken when part of prototype chain since we ignore the receiver
get(target: any, key: PropertyKey) {
if (objectHasOwnProperty.call(specialHandlers, key)) {
return (specialHandlers as any)[key];
}
observeTargetKey(target, key, callback);
return possiblyReactive(target[key], callback);
onReadTargetKey(target, key);
return possiblyReactive(target[key]);
},
}) as ProxyHandler<T>;
}
+72
View File
@@ -0,0 +1,72 @@
import { getTaskContext, TaskContext, useTaskContext } from "./cancellableContext";
export class Task<T = any> {
_promise: Promise<T>;
_ctx?: TaskContext = getTaskContext();
constructor(
executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason: any) => void) => void,
public _onCancelled?: Function
) {
if (!this._ctx) {
this._promise = new Promise(executor);
return;
}
this._promise = new Promise((resolve, reject) => {
try {
executor(
(value: T | PromiseLike<T>) => {
if (!this._ctx?.isCancelled) resolve(value);
},
(error: any) => {
if (!this._ctx?.isCancelled) reject(error);
}
);
} catch (err) {
if (!this._ctx?.isCancelled) reject(err);
}
});
}
then(onFulfilled: (value: any) => any, onRejected: (error: any) => any) {
if (!this._ctx) return this._promise.then(onFulfilled, onRejected);
return this._promise.then((v) => {
if (this._ctx!.isCancelled) return;
let cleanup: Function;
Promise.resolve().then(() => {
const ctx = useTaskContext(this._ctx);
cleanup = ctx.cleanup;
});
const result = onFulfilled(v);
Promise.resolve().then(() => {
cleanup();
});
return result;
}, onRejected);
}
catch(onRejected: (error: any) => any) {
return this._promise.catch(onRejected);
}
finally(onFinally: () => any) {
return this._promise.finally(onFinally);
}
cancel() {
if (this._onCancelled) {
this._onCancelled();
}
}
get [Symbol.toStringTag]() {
return "Promise";
}
// static all(tasks) {
// return new Task((resolve, reject) => {
// Promise.all(tasks.map((t) => (t instanceof Task ? t._promise : t))).then(resolve, reject);
// });
// }
}
+74 -4
View File
@@ -35,13 +35,43 @@ export function inOwnerDocument(el?: HTMLElement) {
return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host);
}
/**
* Determine whether the given element is contained in a specific root documnet:
* either directly or with a shadow root in between or in an iframe.
*/
function isAttachedToDocument(
element: HTMLElement | ShadowRoot,
documentElement: Document
): boolean {
let current: Node = element;
const shadowRoot = documentElement.defaultView!.ShadowRoot;
while (current) {
if (current === documentElement) {
return true;
}
if (current.parentNode) {
current = current.parentNode;
} else if (current instanceof shadowRoot && current.host) {
current = current.host;
} else {
return false;
}
}
return false;
}
export function validateTarget(target: HTMLElement | ShadowRoot) {
// Get the document and HTMLElement corresponding to the target to allow mounting in iframes
const document = target && target.ownerDocument;
if (document) {
const HTMLElement = document.defaultView!.HTMLElement;
if (!document.defaultView) {
throw new OwlError(
"Cannot mount a component: the target document is not attached to a window (defaultView is missing)"
);
}
const HTMLElement = document.defaultView.HTMLElement;
if (target instanceof HTMLElement || target instanceof ShadowRoot) {
if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) {
if (!isAttachedToDocument(target, document)) {
throw new OwlError("Cannot mount a component on a detached dom node");
}
return;
@@ -81,10 +111,50 @@ export async function loadFile(url: string): Promise<string> {
*/
export class Markup extends String {}
export function htmlEscape(str: any): Markup {
if (str instanceof Markup) {
return str;
}
if (str === undefined) {
return markup("");
}
if (typeof str === "number") {
return markup(String(str));
}
[
["&", "&amp;"],
["<", "&lt;"],
[">", "&gt;"],
["'", "&#x27;"],
['"', "&quot;"],
["`", "&#x60;"],
].forEach((pairs) => {
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
});
return markup(str);
}
/*
* Marks a value as safe, that is, a value that can be injected as HTML directly.
* It should be used to wrap the value passed to a t-out directive to allow a raw rendering.
*
* If called as a tag function, the interpolated strings are escaped.
*/
export function markup(value: any) {
return new Markup(value);
export function markup(strings: TemplateStringsArray, ...placeholders: unknown[]): Markup;
export function markup(value: string): Markup;
export function markup(
valueOrStrings: string | TemplateStringsArray,
...placeholders: unknown[]
): Markup {
if (!Array.isArray(valueOrStrings)) {
return new Markup(valueOrStrings);
}
const strings = valueOrStrings;
let acc = "";
let i = 0;
for (; i < placeholders.length; ++i) {
acc += strings[i] + htmlEscape(placeholders[i]);
}
acc += strings[i];
return new Markup(acc);
}
+1 -1
View File
@@ -1,2 +1,2 @@
// do not modify manually. This file is generated by the release script.
export const version = "2.6.1";
export const version = "2.8.1";
-109
View File
@@ -1,51 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Reactivity: useState concurrent renderings 1`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers;
let block1 = createBlock(\`<span><block-text-0/><block-text-1/></span>\`);
return function template(ctx, node, key = \\"\\") {
let d1 = ctx['context'][ctx['props'].key].n;
let d2 = ctx['state'].x;
return block1([d1, d2]);
}
}"
`;
exports[`Reactivity: useState concurrent renderings 2`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers;
let block1 = createBlock(\`<p><block-child-0/></p>\`);
return function template(ctx, node, key = \\"\\") {
let b2 = component(\`ComponentC\`, {key: ctx['props'].key}, key + \`__1\`, node, ctx);
return block1([], [b2]);
}
}"
`;
exports[`Reactivity: useState concurrent renderings 3`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers;
let block1 = createBlock(\`<div><block-child-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let b2 = component(\`ComponentB\`, {key: ctx['context'].key}, key + \`__1\`, node, ctx);
return block1([], [b2]);
}
}"
`;
exports[`Reactivity: useState destroyed component before being mounted is inactive 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -155,69 +109,6 @@ exports[`Reactivity: useState parent and children subscribed to same context 2`]
}"
`;
exports[`Reactivity: useState several nodes on different level use same context 1`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers;
let block1 = createBlock(\`<div><block-text-0/> <block-text-1/></div>\`);
return function template(ctx, node, key = \\"\\") {
let d1 = ctx['contextObj'].a;
let d2 = ctx['contextObj'].b;
return block1([d1, d2]);
}
}"
`;
exports[`Reactivity: useState several nodes on different level use same context 2`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers;
let block1 = createBlock(\`<div><block-text-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let d1 = ctx['contextObj'].b;
return block1([d1]);
}
}"
`;
exports[`Reactivity: useState several nodes on different level use same context 3`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers;
let block1 = createBlock(\`<div><block-text-0/><block-child-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let d1 = ctx['contextObj'].a;
let b2 = component(\`L3A\`, {}, key + \`__1\`, node, ctx);
return block1([d1], [b2]);
}
}"
`;
exports[`Reactivity: useState several nodes on different level use same context 4`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers;
let block1 = createBlock(\`<div><block-child-0/><block-child-1/></div>\`);
return function template(ctx, node, key = \\"\\") {
let b2 = component(\`L2A\`, {}, key + \`__1\`, node, ctx);
let b3 = component(\`L2B\`, {}, key + \`__2\`, node, ctx);
return block1([], [b2, b3]);
}
}"
`;
exports[`Reactivity: useState two components are updated in parallel 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -49,6 +49,27 @@ exports[`debugging t-debug on sub template 2`] = `
}"
`;
exports[`debugging t-debug: interaction with t-set 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
let block1 = createBlock(\`<span><block-text-0/></span>\`);
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
debugger;
setContextValue(ctx, \\"foo\\", 42);
debugger;
setContextValue(ctx, \\"bar\\", 49);
let txt1 = ctx['foo']+ctx['bar'];
return block1([txt1]);
}
}"
`;
exports[`debugging t-log 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -66,3 +87,24 @@ exports[`debugging t-log 1`] = `
}
}"
`;
exports[`debugging t-log: interaction with t-set 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
let block1 = createBlock(\`<span><block-text-0/></span>\`);
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
console.log(ctx['foo']);
setContextValue(ctx, \\"foo\\", 42);
console.log(ctx['bar']);
setContextValue(ctx, \\"bar\\", 49);
let txt1 = ctx['foo']+ctx['bar'];
return block1([txt1]);
}
}"
`;
@@ -103,3 +103,18 @@ exports[`t-key t-key on sub dom node pushes a child block in its parent 2`] = `
}
}"
`;
exports[`t-key t-key: interaction with t-esc 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<p><block-text-0/></p>\`);
return function template(ctx, node, key = \\"\\") {
const tKey_1 = ctx['key'];
let txt1 = ctx['text'];
return toggler(tKey_1, block1([txt1]));
}
}"
`;
@@ -10,8 +10,7 @@ exports[`translation context body of t-sets are translated in context 1`] = `
ctx = Object.create(ctx);
ctx[isBoundary] = 1
setContextValue(ctx, \\"label\\", \`traduit\`);
const b2 = text(ctx['label']);
return multi([b2]);
return text(ctx['label']);
}
}"
`;
@@ -94,6 +93,23 @@ exports[`translation context slot attrs and text contents are translated in cont
}"
`;
exports[`translation context t-translation-context with several children 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div><div/><div/><block-child-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let b2;
if (true) {
b2 = text(\`\`);
}
return block1([], [b2]);
}
}"
`;
exports[`translation context translation of attributes in context 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -153,6 +169,21 @@ exports[`translation support body of t-sets inside translation=off are not trans
}"
`;
exports[`translation support body of t-sets inside translation=off are not translated 2 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
setContextValue(ctx, \\"label\\", \`untranslated\`);
return text(ctx['label']);
}
}"
`;
exports[`translation support body of t-sets with html content are translated 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -264,6 +295,23 @@ exports[`translation support t-set and falsy t-value: t-body are translated 1`]
}"
`;
exports[`translation support t-translation with several children 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div><div/><div/><block-child-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let b2;
if (true) {
b2 = text(\`\`);
}
return block1([], [b2]);
}
}"
`;
exports[`translation support translation is done on the trimmed text, with extra spaces readded after 1`] = `
"function anonymous(app, bdom, helpers
) {
+111 -1
View File
@@ -692,6 +692,7 @@ describe("qweb parser", () => {
value: "value",
defaultValue: null,
body: null,
hasNoRepresentation: true,
});
});
@@ -702,6 +703,7 @@ describe("qweb parser", () => {
defaultValue: "ok",
value: null,
body: null,
hasNoRepresentation: true,
});
expect(parse(`<t t-set="v"><div>ok</div></t>`)).toEqual({
@@ -723,6 +725,7 @@ describe("qweb parser", () => {
content: [{ type: ASTType.Text, value: "ok" }],
},
],
hasNoRepresentation: true,
});
expect(parse(`<t t-set="v"><div>ok</div>abc</t>`)).toEqual({
@@ -745,6 +748,7 @@ describe("qweb parser", () => {
},
{ type: ASTType.Text, value: "abc" },
],
hasNoRepresentation: true,
});
});
@@ -758,6 +762,7 @@ describe("qweb parser", () => {
defaultValue: "ok",
value: null,
body: null,
hasNoRepresentation: true,
},
tElif: null,
tElse: null,
@@ -783,7 +788,14 @@ describe("qweb parser", () => {
condition: "flag",
content: { type: ASTType.Text, value: "1" },
tElif: null,
tElse: { type: ASTType.TSet, name: "ourvar", value: "0", defaultValue: null, body: null },
tElse: {
type: ASTType.TSet,
name: "ourvar",
value: "0",
defaultValue: null,
body: null,
hasNoRepresentation: true,
},
},
],
});
@@ -1971,6 +1983,54 @@ describe("qweb parser", () => {
});
});
test('t-translation="off": interaction with t-esc', async () => {
expect(parse(`<span t-esc="a" t-translation="off"/>`)).toEqual({
type: ASTType.TTranslation,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
defaultValue: "",
expr: "a",
type: ASTType.TEsc,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
});
});
test('t-translation="off": interaction with t-out', async () => {
expect(parse(`<span t-out="a" t-translation="off"/>`)).toEqual({
type: ASTType.TTranslation,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
body: null,
expr: "a",
type: ASTType.TOut,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
});
});
// ---------------------------------------------------------------------------
// t-translation-context
// ---------------------------------------------------------------------------
@@ -2008,6 +2068,56 @@ describe("qweb parser", () => {
});
});
test("t-translation-context: interaction with t-esc", async () => {
expect(parse(`<span t-esc="a" t-translation-context="fr"/>`)).toEqual({
type: ASTType.TTranslationContext,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
defaultValue: "",
expr: "a",
type: ASTType.TEsc,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
translationCtx: "fr",
});
});
test("t-translation-context: interaction with t-out", async () => {
expect(parse(`<span t-out="a" t-translation-context="fr"/>`)).toEqual({
type: ASTType.TTranslationContext,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
body: null,
expr: "a",
type: ASTType.TOut,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
translationCtx: "fr",
});
});
// ---------------------------------------------------------------------------
// t-translation-context-attr
// ---------------------------------------------------------------------------
+30
View File
@@ -38,4 +38,34 @@ describe("debugging", () => {
expect(console.log).toHaveBeenCalledWith(45);
console.log = consoleLog;
});
test("t-log: interaction with t-set", () => {
const consoleLog = console.log;
console.log = jest.fn();
const template = `
<t>
<t t-log="foo" t-set="foo" t-value="42"/>
<t t-log="bar" t-set="bar" t-value="49"/>
<span t-esc="foo + bar"/>
</t>
`;
snapshotTemplate(template);
renderToString(template);
expect(console.log).toHaveBeenCalledWith(undefined);
expect(console.log).toHaveBeenCalledWith(undefined);
console.log = consoleLog;
});
test("t-debug: interaction with t-set", () => {
const template = `
<t>
<t t-debug="" t-set="foo" t-value="42"/>
<t t-debug="" t-set="bar" t-value="49"/>
<span t-esc="foo + bar"/>
</t>
`;
snapshotTemplate(template);
renderToString(template);
});
});
+6
View File
@@ -63,4 +63,10 @@ describe("t-key", () => {
expect(renderToString(template2, { key: "1" })).toBe("<div><h1></h1></div>");
});
test("t-key: interaction with t-esc", async () => {
const template = `<p t-key="key" t-esc="text"/>`;
expect(renderToString(template, { key: "1", text: "abc" })).toBe("<p>abc</p>");
});
});
+47
View File
@@ -129,6 +129,21 @@ describe("translation support", () => {
expect(fixture.innerHTML).toBe("untranslated");
});
test("body of t-sets inside translation=off are not translated 2", async () => {
class SomeComponent extends Component {
static template = xml`
<t>
<t t-translation="off" t-set="label">untranslated</t>
<t t-esc="label"/>
</t>`;
}
const translateFn = () => "translated";
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("untranslated");
});
test("body of t-sets with html content are translated", async () => {
class SomeComponent extends Component {
static template = xml`
@@ -170,6 +185,22 @@ describe("translation support", () => {
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("translated");
});
test("t-translation with several children", async () => {
class SomeComponent extends Component {
static template = xml`
<div>
<t t-translation="off">
<div/>
<div/>
</t>
<t t-if="true"/>
</div>
`;
}
await mount(SomeComponent, fixture);
expect(fixture.outerHTML).toBe("<div><div><div></div><div></div></div></div>");
});
});
describe("translation context", () => {
@@ -293,4 +324,20 @@ describe("translation context", () => {
expect(translateFn).toHaveBeenCalledWith("param", "fr");
expect(translateFn).toHaveBeenCalledWith("title", "pt");
});
test("t-translation-context with several children", async () => {
class SomeComponent extends Component {
static template = xml`
<div>
<t t-translation-context="ctx">
<div/>
<div/>
</t>
<t t-if="true"/>
</div>
`;
}
await mount(SomeComponent, fixture);
expect(fixture.outerHTML).toBe("<div><div><div></div><div></div></div></div>");
});
});
@@ -52,6 +52,36 @@ exports[`reactivity in lifecycle Component is automatically subscribed to reacti
}"
`;
exports[`reactivity in lifecycle an external reactive object should be tracked 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`TestSubComponent\`, true, false, false, []);
let block1 = createBlock(\`<div><block-text-0/><block-child-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let txt1 = ctx['obj1'].value;
const b2 = comp1({}, key + \`__1\`, node, this, null);
return block1([txt1], [b2]);
}
}"
`;
exports[`reactivity in lifecycle an external reactive object should be tracked 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div><block-text-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let txt1 = ctx['obj2'].value;
return block1([txt1]);
}
}"
`;
exports[`reactivity in lifecycle can use a state hook 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -140,39 +170,3 @@ exports[`reactivity in lifecycle state changes in willUnmount do not trigger rer
}
}"
`;
exports[`subscriptions subscriptions returns the keys and targets observed by the component 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(ctx['state'].a);
}
}"
`;
exports[`subscriptions subscriptions returns the keys observed by the component 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, [\\"state\\"]);
return function template(ctx, node, key = \\"\\") {
const b2 = text(ctx['state'].a);
const b3 = comp1({state: ctx['state']}, key + \`__1\`, node, this, null);
return multi([b2, b3]);
}
}"
`;
exports[`subscriptions subscriptions returns the keys observed by the component 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(ctx['props'].state.b);
}
}"
`;
+10 -9
View File
@@ -1,27 +1,28 @@
import { App, Component, mount, onWillDestroy } from "../../src";
import { OwlError } from "../../src/common/owl_error";
import {
onError,
onMounted,
onPatched,
onWillPatch,
onWillStart,
onWillRender,
onRendered,
onWillPatch,
onWillRender,
onWillStart,
onWillUnmount,
useState,
xml,
} from "../../src/index";
import { getCurrent } from "../../src/runtime/component_node";
import {
logStep,
makeTestFixture,
nextTick,
nextMicroTick,
snapshotEverything,
useLogLifecycle,
nextAppError,
nextMicroTick,
nextTick,
snapshotEverything,
steps,
useLogLifecycle,
} from "../helpers";
import { OwlError } from "../../src/common/owl_error";
let fixture: HTMLElement;
@@ -647,7 +648,7 @@ describe("can catch errors", () => {
setup() {
onWillStart(() => {
this.state = useState({ value: 2 });
getCurrent();
});
}
}
-4
View File
@@ -450,14 +450,10 @@ test(".alike suffix in a list", async () => {
expect(fixture.innerHTML).toBe("<button>1V</button><button>2V</button>");
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Parent:willRender",
"Parent:rendered",
"Todo:willRender",
"Todo:rendered",
"Todo:willPatch",
"Todo:patched",
"Parent:willPatch",
"Parent:patched",
]
`);
});
+1 -1
View File
@@ -702,7 +702,7 @@ describe("props validation", () => {
const app = new App(Parent, { test: true });
await app.mount(fixture);
expect(fixture.innerHTML).toBe("12");
expect(app.root!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
// expect(app.root!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
});
test("props are validated whenever component is updated", async () => {
+38 -43
View File
@@ -2,12 +2,11 @@ import {
Component,
mount,
onPatched,
onWillRender,
onWillPatch,
onWillRender,
onWillUnmount,
useState,
reactive,
xml,
toRaw,
} from "../../src";
import { makeTestFixture, nextTick, snapshotEverything, steps, useLogLifecycle } from "../helpers";
@@ -20,10 +19,36 @@ beforeEach(() => {
});
describe("reactivity in lifecycle", () => {
test("an external reactive object should be tracked", async () => {
const obj1 = reactive({ value: 1 });
const obj2 = reactive({ value: 100 });
class TestSubComponent extends Component {
obj2 = obj2;
static template = xml`<div>
<t t-esc="obj2.value"/>
</div>`;
}
class TestComponent extends Component {
obj1 = obj1;
static template = xml`<div>
<t t-esc="obj1.value"/>
<TestSubComponent/>
</div>`;
static components = { TestSubComponent };
}
await mount(TestComponent, fixture);
expect(fixture.innerHTML).toBe("<div>1<div>100</div></div>");
obj1.value = 2;
obj2.value = 200;
await nextTick();
expect(fixture.innerHTML).toBe("<div>2<div>200</div></div>");
});
test("can use a state hook", async () => {
class Counter extends Component {
static template = xml`<div><t t-esc="counter.value"/></div>`;
counter = useState({ value: 42 });
counter = reactive({ value: 42 });
}
const counter = await mount(Counter, fixture);
expect(fixture.innerHTML).toBe("<div>42</div>");
@@ -36,7 +61,7 @@ describe("reactivity in lifecycle", () => {
let n = 0;
class Comp extends Component {
static template = xml`<div><t t-esc="state.a"/></div>`;
state = useState({ a: 5, b: 7 });
state = reactive({ a: 5, b: 7 });
setup() {
onWillRender(() => n++);
}
@@ -57,7 +82,7 @@ describe("reactivity in lifecycle", () => {
test("can use a state hook on Map", async () => {
class Counter extends Component {
static template = xml`<div><t t-esc="counter.get('value')"/></div>`;
counter = useState(new Map([["value", 42]]));
counter = reactive(new Map([["value", 42]]));
}
const counter = await mount(Counter, fixture);
expect(fixture.innerHTML).toBe("<div>42</div>");
@@ -72,7 +97,7 @@ describe("reactivity in lifecycle", () => {
static template = xml`
<span><t t-esc="props.val"/><t t-esc="state.n"/></span>
`;
state = useState({ n: 2 });
state = reactive({ n: 2 });
setup() {
onWillRender(() => {
steps.push("render");
@@ -96,7 +121,7 @@ describe("reactivity in lifecycle", () => {
</div>
`;
static components = { Child };
state = useState({ val: 1, flag: true });
state = reactive({ val: 1, flag: true });
}
const parent = await mount(Parent, fixture);
expect(steps).toEqual(["render"]);
@@ -142,7 +167,7 @@ describe("reactivity in lifecycle", () => {
static template = xml`
<div><t t-esc="state.val"/></div>
`;
state = useState({ val: 1 });
state = reactive({ val: 1 });
setup() {
STATE = this.state;
onWillRender(() => {
@@ -167,7 +192,7 @@ describe("reactivity in lifecycle", () => {
class Parent extends Component {
static template = xml`<Child t-if="state.renderChild" state="state"/>`;
static components = { Child };
state: any = useState({ renderChild: true, content: { a: 2 } });
state: any = reactive({ renderChild: true, content: { a: 2 } });
setup() {
useLogLifecycle();
}
@@ -205,7 +230,8 @@ describe("reactivity in lifecycle", () => {
`);
});
test("Component is automatically subscribed to reactive object received as prop", async () => {
// todo: unskip it
test.skip("Component is automatically subscribed to reactive object received as prop", async () => {
let childRenderCount = 0;
let parentRenderCount = 0;
class Child extends Component {
@@ -218,7 +244,7 @@ describe("reactivity in lifecycle", () => {
static template = xml`<Child obj="obj" reactiveObj="reactiveObj"/>`;
static components = { Child };
obj = { a: 1 };
reactiveObj = useState({ b: 2 });
reactiveObj = reactive({ b: 2 });
setup() {
onWillRender(() => parentRenderCount++);
}
@@ -237,34 +263,3 @@ describe("reactivity in lifecycle", () => {
expect(fixture.innerHTML).toBe("34");
});
});
describe("subscriptions", () => {
test("subscriptions returns the keys and targets observed by the component", async () => {
class Comp extends Component {
static template = xml`<t t-esc="state.a"/>`;
state = useState({ a: 1, b: 2 });
}
const comp = await mount(Comp, fixture);
expect(fixture.innerHTML).toBe("1");
expect(comp.__owl__.subscriptions).toEqual([{ keys: ["a"], target: toRaw(comp.state) }]);
});
test("subscriptions returns the keys observed by the component", async () => {
class Child extends Component {
static template = xml`<t t-esc="props.state.b"/>`;
setup() {
child = this;
}
}
let child: Child;
class Parent extends Component {
static template = xml`<t t-esc="state.a"/><Child state="state"/>`;
static components = { Child };
state = useState({ a: 1, b: 2 });
}
const parent = await mount(Parent, fixture);
expect(fixture.innerHTML).toBe("12");
expect(parent.__owl__.subscriptions).toEqual([{ keys: ["a"], target: toRaw(parent.state) }]);
expect(child!.__owl__.subscriptions).toEqual([{ keys: ["b"], target: toRaw(parent.state) }]);
});
});
-4
View File
@@ -330,12 +330,8 @@ describe("rendering semantics", () => {
expect(fixture.innerHTML).toBe("444");
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Parent:willRender",
"Parent:rendered",
"Child:willRender",
"Child:rendered",
"Parent:willPatch",
"Parent:patched",
"Child:willPatch",
"Child:patched",
]
+109
View File
@@ -0,0 +1,109 @@
import { taskEffect } from "../../src/runtime/cancellableContext";
import { Task } from "../../src/runtime/task";
export type Deffered = Promise<any> & {
resolve: (value: any) => void;
reject: (reason: any) => void;
};
interface TaskWithResolvers<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
let resolvers: Record<string, TaskWithResolvers<string>> = {};
function getTask(id: string) {
const resolver: {
task?: Task<string>;
resolve?: (value: string | PromiseLike<string>) => void;
reject?: (reason?: any) => void;
} = {};
const promise = new Task<string>((res, rej) => {
resolver.resolve = res;
resolver.reject = rej;
});
resolver.task = promise;
resolvers[id] = resolver as TaskWithResolvers<string>;
return promise;
}
function tick() {
return new Promise((r) => setTimeout(r, 0));
}
// const timeoutTask = (ms: number) => new Task((resolve) => setTimeout(() => resolve(ms), ms));
const steps: string[] = [];
function step(msg: string) {
steps.push(msg);
}
function verifySteps(expected: string[]) {
expect(steps).toEqual(expected);
steps.length = 0;
}
afterEach(() => {
resolvers = {};
});
describe("task", () => {
test("should run a task properly", async () => {
taskEffect(async () => {
let result;
step(`a:begin`);
result = await getTask("a");
step(`a:${result}`);
result = await getTask("b");
step(`b:${result}`);
});
verifySteps(["a:begin"]);
resolvers["a"].resolve("a");
await tick();
verifySteps(["a:a"]);
resolvers["b"].resolve("b");
await tick();
verifySteps(["b:b"]);
});
test.only("should cancel a task properly", async () => {
const ctx = taskEffect(async () => {
let result;
step(`a:begin`);
result = await getTask("a");
step(`a:${result}`);
result = await getTask("b");
step(`b:${result}`);
});
verifySteps(["a:begin"]);
resolvers["a"].resolve("a");
await tick();
verifySteps(["a:a"]);
ctx.cancel();
resolvers["b"].resolve("b");
await tick();
verifySteps([]);
});
test("should run a task with subtasks properly", async () => {
taskEffect(async () => {
let result;
step(`a:begin`);
result = await getTask("a");
step(`a:${result}`);
result = await getTask("b");
step(`b:${result}`);
});
verifySteps(["a:begin"]);
resolvers["a"].resolve("a");
await tick();
verifySteps(["a:a"]);
resolvers["b"].resolve("b");
await tick();
verifySteps(["b:b"]);
});
});
-4
View File
@@ -458,10 +458,8 @@ describe("Portal", () => {
"parent:willPatch",
"child:mounted",
"parent:patched",
"parent:willPatch",
"child:willPatch",
"child:patched",
"parent:patched",
]);
expect(fixture.innerHTML).toBe('<div id="outside"><span>2</span></div><div></div>');
@@ -472,10 +470,8 @@ describe("Portal", () => {
"parent:willPatch",
"child:mounted",
"parent:patched",
"parent:willPatch",
"child:willPatch",
"child:patched",
"parent:patched",
"parent:willPatch",
"child:willUnmount",
"parent:patched",
+918 -968
View File
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,58 @@ exports[`shadow_dom can mount app 1`] = `
}"
`;
exports[`shadow_dom can mount app in closed shadow dom 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside a separate HTML document 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside a shadow child element 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside an element in a shadow root inside an iframe 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom useRef hook 1`] = `
"function anonymous(app, bdom, helpers
) {
+87
View File
@@ -29,6 +29,24 @@ describe("shadow_dom", () => {
expect(status(comp)).toBe("destroyed");
});
test("can mount app in closed shadow dom", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const container = document.createElement("div");
fixture.appendChild(container);
const shadow = container.attachShadow({ mode: "closed" });
const app = new App(SomeComponent);
const comp = await app.mount(shadow);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
test("can bind event handler", async () => {
let a = 1;
class SomeComponent extends Component {
@@ -64,4 +82,73 @@ describe("shadow_dom", () => {
await mountedProm;
expect(comp!.div.el).toBe(shadow.querySelector(".my-div"));
});
test("can mount app inside a shadow child element", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const shadow = fixture.attachShadow({ mode: "open" });
const shadowDiv = document.createElement("div");
shadow.append(shadowDiv);
const app = new App(SomeComponent);
const comp = await app.mount(shadowDiv);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
test("can mount app inside a separate HTML document", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const separateDoc = document.implementation.createHTMLDocument();
const container = separateDoc.createElement("div");
separateDoc.body.appendChild(container);
const app = new App(SomeComponent);
let error: Error;
try {
await app.mount(container);
} catch (e) {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Cannot mount a component: the target document is not attached to a window (defaultView is missing)"
);
});
test("can mount app inside an element in a shadow root inside an iframe", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const iframe = document.createElement("iframe");
fixture.appendChild(iframe);
const iframeDoc = iframe.contentDocument!;
const container = iframeDoc.createElement("div");
iframeDoc.body.appendChild(container);
const shadow = container.attachShadow({ mode: "open" });
const shadowTarget = iframeDoc.createElement("div");
shadow.appendChild(shadowTarget);
const app = new App(SomeComponent);
const comp = await app.mount(shadowTarget);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
expect(iframeDoc.body.contains(container)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
});
+92 -1
View File
@@ -1,4 +1,4 @@
import { batched, EventBus } from "../src/runtime/utils";
import { batched, EventBus, htmlEscape, markup } from "../src/runtime/utils";
import { nextMicroTick } from "./helpers";
describe("event bus behaviour", () => {
@@ -71,3 +71,94 @@ describe("batched", () => {
expect(n).toBe(2);
});
});
const Markup = markup("").constructor;
describe("markup", () => {
test("string is flagged as safe", () => {
const html = markup("<blink>Hello</blink>");
expect(html).toBeInstanceOf(Markup);
});
describe("htmlEscape", () => {
test("htmlEscape escapes text", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape keeps html markup", () => {
const res = htmlEscape(markup("<p>test</p>"));
expect(res.toString()).toBe("<p>test</p>");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces empty string on undefined", () => {
const res = htmlEscape(undefined);
expect(res.toString()).toBe("");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces string from number", () => {
const res = htmlEscape(10);
expect(res.toString()).toBe("10");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces string from boolean", () => {
const res = htmlEscape(false);
expect(res.toString()).toBe("false");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape correctly escapes various links", () => {
expect(htmlEscape("<a>this is a link</a>").toString()).toBe(
"&lt;a&gt;this is a link&lt;/a&gt;"
);
expect(htmlEscape(`<a href="https://www.odoo.com">odoo<a>`).toString()).toBe(
`&lt;a href=&quot;https://www.odoo.com&quot;&gt;odoo&lt;a&gt;`
);
expect(htmlEscape(`<a href='https://www.odoo.com'>odoo<a>`).toString()).toBe(
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;odoo&lt;a&gt;`
);
expect(htmlEscape("<a href='https://www.odoo.com'>Odoo`s website<a>").toString()).toBe(
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;Odoo&#x60;s website&lt;a&gt;`
);
});
test("htmlEscape doesn't escape already escaped content", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res).toBeInstanceOf(Markup);
const res2 = htmlEscape(res);
expect(res2.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res2).toBeInstanceOf(Markup);
expect(res2).toBe(res);
});
test("htmlEscape returns markup even for only-safe text", () => {
const res = htmlEscape("safe");
expect(res.toString()).toBe("safe");
expect(res).toBeInstanceOf(Markup);
});
});
describe("tag function", () => {
test("interpolated values are escaped", () => {
const maliciousInput = "<script>alert('💥💥')</script>";
const html = markup`<b>${maliciousInput}</b>`;
expect(html.toString()).toBe("<b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>");
expect(html).toBeInstanceOf(Markup);
});
test("interpolated markups aren't escaped", () => {
const shouldBeEscaped = "<script>alert('should be escaped')</script>";
const shouldnt = markup("<b>this is safe</b>");
const html = markup`<div>${shouldBeEscaped} ${shouldnt}</div>`;
expect(html.toString()).toBe(
"<div>&lt;script&gt;alert(&#x27;should be escaped&#x27;)&lt;/script&gt; <b>this is safe</b></div>"
);
expect(html).toBeInstanceOf(Markup);
});
test("quotes in interpolated values are escaped", () => {
const imgUrl = `lol" onerror="alert('xss')`;
const html = markup`<img src="${imgUrl}">`;
expect(html.toString()).toBe(`<img src="lol&quot; onerror=&quot;alert(&#x27;xss&#x27;)">`);
});
test("already escaped content is not escaped again", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
const html = markup`${res}`;
expect(html.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
});
});
});