feat: renderer first render 😱

This commit is contained in:
Gervwyk 2020-10-13 18:37:54 +02:00
parent f82d594614
commit a8b7f80659
28 changed files with 460 additions and 77 deletions

26
.pnp.js generated
View File

@ -3632,7 +3632,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["react-dom", "virtual:acf8ebf79a461c4f9f2ee32d35604720e52d54fc65cacbf3e944b2ad79cd4f17f95a2cb60cf4b37ca284c4f89981c732c25542cf20e548286d7eb31af1a6edda#npm:17.0.0-rc.3"],
["webpack", "virtual:acf8ebf79a461c4f9f2ee32d35604720e52d54fc65cacbf3e944b2ad79cd4f17f95a2cb60cf4b37ca284c4f89981c732c25542cf20e548286d7eb31af1a6edda#npm:5.0.0"],
["webpack-cli", "virtual:acf8ebf79a461c4f9f2ee32d35604720e52d54fc65cacbf3e944b2ad79cd4f17f95a2cb60cf4b37ca284c4f89981c732c25542cf20e548286d7eb31af1a6edda#npm:3.3.12"],
["webpack-dev-server", "virtual:acf8ebf79a461c4f9f2ee32d35604720e52d54fc65cacbf3e944b2ad79cd4f17f95a2cb60cf4b37ca284c4f89981c732c25542cf20e548286d7eb31af1a6edda#npm:3.11.0"]
["webpack-dev-server", "virtual:acf8ebf79a461c4f9f2ee32d35604720e52d54fc65cacbf3e944b2ad79cd4f17f95a2cb60cf4b37ca284c4f89981c732c25542cf20e548286d7eb31af1a6edda#npm:3.11.0"],
["webpack-merge", "npm:5.2.0"]
],
"linkType": "SOFT",
}]
@ -3839,7 +3840,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["style-loader", "virtual:e7dd2bdbec1b3ec399e5f3318d0a58728583b58181f43cb8f4f372a1b2b9707e2ffcf76bd80aad3c5c64a731754028a8070020628ca4fa0a02fe260c179762ae#npm:2.0.0"],
["webpack", "virtual:e7dd2bdbec1b3ec399e5f3318d0a58728583b58181f43cb8f4f372a1b2b9707e2ffcf76bd80aad3c5c64a731754028a8070020628ca4fa0a02fe260c179762ae#npm:5.0.0"],
["webpack-cli", "virtual:e7dd2bdbec1b3ec399e5f3318d0a58728583b58181f43cb8f4f372a1b2b9707e2ffcf76bd80aad3c5c64a731754028a8070020628ca4fa0a02fe260c179762ae#npm:3.3.12"],
["webpack-dev-server", "virtual:e7dd2bdbec1b3ec399e5f3318d0a58728583b58181f43cb8f4f372a1b2b9707e2ffcf76bd80aad3c5c64a731754028a8070020628ca4fa0a02fe260c179762ae#npm:3.11.0"]
["webpack-dev-server", "virtual:e7dd2bdbec1b3ec399e5f3318d0a58728583b58181f43cb8f4f372a1b2b9707e2ffcf76bd80aad3c5c64a731754028a8070020628ca4fa0a02fe260c179762ae#npm:3.11.0"],
["webpack-merge", "npm:5.2.0"]
],
"linkType": "SOFT",
}]
@ -21614,6 +21616,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"linkType": "HARD",
}]
]],
["webpack-merge", [
["npm:5.2.0", {
"packageLocation": "./.yarn/cache/webpack-merge-npm-5.2.0-dfa3a85026-edc100b9c7.zip/node_modules/webpack-merge/",
"packageDependencies": [
["webpack-merge", "npm:5.2.0"],
["clone-deep", "npm:4.0.1"],
["wildcard", "npm:2.0.0"]
],
"linkType": "HARD",
}]
]],
["webpack-sources", [
["npm:1.4.3", {
"packageLocation": "./.yarn/cache/webpack-sources-npm-1.4.3-2b3a9b1de0-2a753b36ad.zip/node_modules/webpack-sources/",
@ -21749,6 +21762,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"linkType": "HARD",
}]
]],
["wildcard", [
["npm:2.0.0", {
"packageLocation": "./.yarn/cache/wildcard-npm-2.0.0-baedca033a-207baede4d.zip/node_modules/wildcard/",
"packageDependencies": [
["wildcard", "npm:2.0.0"]
],
"linkType": "HARD",
}]
]],
["windows-release", [
["npm:3.3.3", {
"packageLocation": "./.yarn/cache/windows-release-npm-3.3.3-51824464bb-87a218d7e1.zip/node_modules/windows-release/",

Binary file not shown.

Binary file not shown.

View File

@ -25,7 +25,7 @@ const defaultMethods = (methods) => ({
...methods,
});
const connectBlock = (Comp) => {
const blockDefaults = (Comp) => {
return ({
actions,
blockId,
@ -53,4 +53,4 @@ const connectBlock = (Comp) => {
);
};
export default connectBlock;
export default blockDefaults;

View File

@ -16,17 +16,17 @@
import createEmotion from 'create-emotion';
const windowContext = window || {};
let emotionCss = null;
const getEmotionCss = () => {
try {
if (!windowContext.emotion) {
if (!emotionCss) {
const { css } = createEmotion({
container: document.getElementById('emotion'),
});
windowContext.emotion = { css };
emotionCss = css;
}
return windowContext.emotion.css;
return emotionCss;
} catch (error) {
throw new Error('Emotion failed to initilize: ' + error.message);
}

View File

@ -14,7 +14,7 @@
limitations under the License.
*/
import connectBlock from './connectBlock';
import blockDefaults from './blockDefaults';
import ErrorBoundary from './ErrorBoundary';
import getEmotionCss from './getEmotionCss';
import Loading from './Loading';
@ -23,7 +23,7 @@ import mediaToCssObject from './mediaToCssObject.js';
import useRunAfterUpdate from './useRunAfterUpdate';
export {
connectBlock,
blockDefaults,
ErrorBoundary,
getEmotionCss,
Loading,

View File

@ -1,4 +1,28 @@
{
"id": "page:page1",
"type": "PageSiderMenu"
}
"id": "block:page1:page1",
"blockId": "page1",
"type": "Context",
"meta": {
"url": "http://localhost:3002/remoteEntry.js",
"scope": "lowdefy_blocks_antd",
"module": "Context",
"category": "context"
},
"areas": {
"content": {
"blocks": [
{
"id": "block:page1:btn",
"blockId": "btn",
"meta": {
"url": "http://localhost:3002/remoteEntry.js",
"scope": "lowdefy_blocks_antd",
"module": "Button",
"category": "display"
},
"type": "Button"
}
]
}
}
}

View File

@ -22,10 +22,11 @@
"url": "https://github.com/lowdefy/lowdefy.git"
},
"scripts": {
"build": "webpack",
"build": "webpack --config webpack.prod.js",
"clean": "rm -rf shell/dist",
"prepublishOnly": "yarn build",
"start": "nodemon server.js"
"start": "nodemon server.js",
"dev": "webpack-dev-server --open --config webpack.dev.js"
},
"dependencies": {
"@lowdefy/graphql": "0.0.0-experimental.0",
@ -44,6 +45,7 @@
"html-webpack-plugin": "4.5.0",
"webpack": "5.0.0",
"webpack-cli": "3.3.12",
"webpack-dev-server": "3.11.0"
"webpack-dev-server": "3.11.0",
"webpack-merge": "5.2.0"
}
}

View File

@ -6,11 +6,6 @@ const deps = require('./package.json').dependencies;
module.exports = {
entry: './shell/src/index',
mode: 'production',
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3003,
},
output: {
path: path.resolve(__dirname, 'shell/dist'),
},

View File

@ -0,0 +1,12 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const path = require('path');
module.exports = merge(common, {
mode: 'development',
devtool: 'eval-source-map',
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3001,
},
});

View File

@ -0,0 +1,6 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'production',
});

View File

@ -54,13 +54,14 @@
"style-loader": "2.0.0",
"webpack": "5.0.0",
"webpack-cli": "3.3.12",
"webpack-dev-server": "3.11.0"
"webpack-dev-server": "3.11.0",
"webpack-merge": "5.2.0"
},
"scripts": {
"start": "webpack-dev-server",
"build": "webpack --mode production",
"build": "webpack --config webpack.prod.js",
"clean": "rm -rf dist",
"prepublishOnly": "yarn build",
"serve": "serve dist -p 3001",
"clean": "rm -rf dist"
"start": "webpack-dev-server --config webpack.dev.js"
}
}

View File

@ -22,7 +22,7 @@ import { ErrorBoundary } from '@lowdefy/block-tools';
import get from '@lowdefy/get';
import useGqlClient from './utils/graphql/useGqlClient';
import PageContext from './page/PageContext';
import Page from './page/Page';
// eslint-disable-next-line no-undef
const windowContext = window;
@ -118,7 +118,7 @@ const Root = () => {
</Route>
<Route exact path="/:pageId">
<ErrorBoundary>
<PageContext rootContext={rootContext} />
<Page rootContext={rootContext} />
</ErrorBoundary>
</Route>
</Switch>

View File

@ -16,7 +16,7 @@
import React, { Suspense } from 'react';
import { useQuery, gql } from '@apollo/client';
import { Loading, makeCssClass, connectBlock, ErrorBoundary } from '@lowdefy/block-tools';
import { Loading, makeCssClass, blockDefaults, ErrorBoundary } from '@lowdefy/block-tools';
import AutoBlock from './AutoBlock';
import prepareBlock from './prepareBlock';
@ -72,7 +72,7 @@ const BindAutoBlock = ({ block, Blocks, context, pageId, rootContext }) => {
<AutoBlock
block={block}
Blocks={Blocks}
Component={connectBlock(Component)}
Component={blockDefaults(Component)}
context={context}
pageId={pageId}
rootContext={rootContext}

View File

@ -16,17 +16,17 @@
limitations under the License.
*/
import React, { Suspense } from 'react';
import React from 'react';
import { useParams, useHistory, useLocation, Redirect } from 'react-router-dom';
import { useQuery, gql } from '@apollo/client';
import { Loading, connectBlock } from '@lowdefy/block-tools';
import { Loading } from '@lowdefy/block-tools';
import get from '@lowdefy/get';
import { urlQuery } from '@lowdefy/helpers';
import AutoBlock from './AutoBlock';
import Helmet from './Helmet';
import prepareBlock from './prepareBlock';
import Block from './block/Block';
import Context from './block/Context';
const GET_PAGE = gql`
query getPage($id: ID!) {
@ -40,42 +40,52 @@ const PageContext = ({ rootContext }) => {
const { search } = useLocation();
rootContext.urlQuery = urlQuery.parse(search || '');
const { loading, error, data } = useQuery(GET_PAGE, {
variables: { id: pageId, branch: rootContext.branch },
variables: { id: pageId },
});
if (loading) {
console.log('loading');
return Loading;
return <Loading />;
}
// if (error) throw error;
if (error) {
console.log(error);
return <div>Error</div>;
}
console.log('finished loading');
// redirect 404
if (!data.page) return <Redirect to="/404" />;
console.log('data', data.page);
const Component = prepareBlock({
block: data.page,
Components: rootContext.Components,
});
return (
<>
<Helmet pageProperties={get(data.page, 'properties', { default: {} })} />
<div>Hello</div>
<div id={pageId}>
<Suspense fallback={<Loading />}>
<AutoBlock
block={data.page}
Blocks={null}
Component={connectBlock(Component)}
context={null}
pageId={pageId}
rootContext={rootContext}
/>
</Suspense>
<Context
block={{
id: `root:${pageId}`,
blockId: `root:${pageId}`,
type: 'Context',
meta: {
category: 'context',
},
areas: { root: { blocks: [data.page] } },
}}
context={null}
contextId={`root:${pageId}`}
pageId={pageId}
rootContext={rootContext}
render={(context) => {
console.log('Page', context);
return (
<Block
block={context.RootBlocks.map[data.page.blockId]}
Blocks={context.RootBlocks}
context={context}
pageId={pageId}
rootContext={rootContext}
/>
);
}}
/>
</div>
</>
);

View File

@ -0,0 +1,46 @@
import React, { Suspense } from 'react';
import { ErrorBoundary, Loading } from '@lowdefy/block-tools';
import LoadBlock from './LoadBlock';
import Defaults from './Defaults';
import CategorySwitch from './CategorySwitch';
import WatchCache from './WatchCache';
const Block = ({ block, Blocks, context, pageId, rootContext }) => {
return (
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<LoadBlock
meta={block.meta}
render={(Comp) => (
<Defaults
Component={Comp}
render={(CompWithDefaults) => {
console.log('block', context);
return (
<WatchCache
block={block}
rootContext={rootContext}
render={() => (
<CategorySwitch
Component={CompWithDefaults}
block={block}
Blocks={Blocks}
context={context}
pageId={pageId}
rootContext={rootContext}
/>
)}
/>
);
}}
/>
)}
/>
</Suspense>
</ErrorBoundary>
);
};
export default Block;

View File

@ -0,0 +1,143 @@
/*
Copyright 2020 Lowdefy, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { BlockLayout } from '@lowdefy/layout';
import { makeContextId } from '@lowdefy/engine';
import { makeCssClass } from '@lowdefy/block-tools';
import Container from './Container';
import Context from './Context';
import List from './List';
const CategorySwitch = ({ block, Blocks, Component, context, pageId, rootContext }) => {
switch (block.meta.category) {
case 'context':
return (
<Context
block={block}
context={context}
contextId={makeContextId({
// TODO: remove branch
branch: 'main',
urlQuery: rootContext.urlQuery,
pageId,
blockId: block.blockId,
})}
pageId={pageId}
rootContext={rootContext}
render={(context) => (
<Container
block={context.RootBlocks.areas.root.blocks[0]}
Blocks={context.RootBlocks}
Component={Component}
context={context}
pageId={pageId}
rootContext={rootContext}
/>
)}
/>
);
case 'list':
return (
<List
block={block}
Blocks={Blocks}
Component={Component}
context={context}
pageId={pageId}
rootContext={rootContext}
/>
);
case 'container':
return (
<Container
block={block}
Blocks={Blocks}
Component={Component}
context={context}
pageId={pageId}
rootContext={rootContext}
/>
);
case 'input':
return (
<BlockLayout
id={`bl-${block.blockId}`}
blockStyle={block.eval.style}
highlightBorders={rootContext.lowdefyGlobal.highlightBorders}
layout={block.eval.layout || {}}
makeCssClass={makeCssClass}
>
<Component
methods={{
callAction: block.callAction,
makeCssClass,
registerAction: block.registerAction,
registerMethod: block.registerMethod,
setValue: block.setValue,
}}
actions={block.eval.actions}
blockId={block.blockId}
Components={rootContext.Components}
homePageId={rootContext.homePageId}
key={block.blockId}
loading={block.loading}
menus={rootContext.menus}
pageId={pageId}
properties={block.eval.properties}
required={block.eval.required}
user={rootContext.user}
validate={block.eval.validate}
value={block.value}
/>
</BlockLayout>
);
default:
return (
<BlockLayout
id={`bl-${block.blockId}`}
blockStyle={block.eval.style}
highlightBorders={rootContext.lowdefyGlobal.highlightBorders}
layout={block.eval.layout || {}}
makeCssClass={makeCssClass}
>
<Component
methods={{
callAction: block.callAction,
makeCssClass,
registerAction: block.registerAction,
registerMethod: block.registerMethod,
}}
actions={block.eval.actions}
blockId={block.blockId}
Components={rootContext.Components}
homePageId={rootContext.homePageId}
key={block.blockId}
loading={block.loading}
menus={rootContext.menus}
pageId={pageId}
properties={block.eval.properties}
required={block.eval.required}
user={rootContext.user}
validate={block.eval.validate}
/>
</BlockLayout>
);
}
};
export default CategorySwitch;

View File

@ -16,11 +16,11 @@
import React from 'react';
import { Area, BlockLayout, layoutParamsToArea } from '@lowdefy/layout';
import { connectBlock, makeCssClass } from '@lowdefy/block-tools';
import { blockDefaults, makeCssClass } from '@lowdefy/block-tools';
import BindAutoBlock from './BindAutoBlock';
import Block from './Block';
const ConnectedArea = connectBlock(Area);
const ConnectedArea = blockDefaults(Area);
const Container = ({ block, Blocks, Component, context, pageId, rootContext }) => {
const content = {};
@ -41,7 +41,7 @@ const Container = ({ block, Blocks, Component, context, pageId, rootContext }) =
makeCssClass={makeCssClass}
>
{areas[areaKey].blocks.map((bl) => (
<BindAutoBlock
<Block
key={`co-${bl.blockId}`}
Blocks={Blocks.subBlocks[block.id][0]}
block={bl}

View File

@ -18,11 +18,11 @@ import React from 'react';
import { Loading, makeCssClass } from '@lowdefy/block-tools';
import useContext from './useContext';
import Container from './Container';
const Context = ({ block, Component, pageId, rootContext, contextId }) => {
const Context = ({ block, contextId, pageId, render, rootContext }) => {
const { context, loading, error } = useContext({ block, pageId, rootContext, contextId });
if (loading) {
// TODO
return (
<Loading
meta={block.meta}
@ -33,16 +33,7 @@ const Context = ({ block, Component, pageId, rootContext, contextId }) => {
}
if (error) throw error;
return (
<Container
block={context.RootBlocks.areas.root.blocks[0]}
Blocks={context.RootBlocks}
Component={Component}
context={context}
pageId={pageId}
rootContext={rootContext}
/>
);
return render(context);
};
export default Context;

View File

@ -0,0 +1,6 @@
import React from 'react';
import { blockDefaults } from '@lowdefy/block-tools';
const Defaults = ({ Component, render }) => render(blockDefaults(Component));
export default Defaults;

View File

@ -16,11 +16,11 @@
import React from 'react';
import { Area, BlockLayout, layoutParamsToArea } from '@lowdefy/layout';
import { connectBlock, makeCssClass } from '@lowdefy/block-tools';
import { blockDefaults, makeCssClass } from '@lowdefy/block-tools';
import BindAutoBlock from './BindAutoBlock';
import Block from './Block';
const ConnectedArea = connectBlock(Area);
const ConnectedArea = blockDefaults(Area);
const List = ({ block, Blocks, Component, context, pageId, rootContext }) => {
const content = {};
@ -41,7 +41,7 @@ const List = ({ block, Blocks, Component, context, pageId, rootContext }) => {
makeCssClass={makeCssClass}
>
{SBlock.areas[areaKey].blocks.map((bl) => (
<BindAutoBlock
<Block
key={`ls-${bl.blockId}`}
Blocks={SBlock}
block={bl}

View File

@ -0,0 +1,41 @@
/*
Copyright 2020 Lowdefy, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { lazy } from 'react';
import useDynamicScript from '../../utils/useDynamicScript';
import loadComponent from '../../utils/loadComponent';
const Components = {};
const LoadBlock = ({ meta, render }) => {
const typeId = `${meta.scope}:${meta.module}`;
console.log('LoadBlock', meta);
const { ready, failed } = useDynamicScript({
url: meta.url,
});
if (!Components[typeId]) {
if (!ready) {
return <h2>Loading dynamic script: {meta.url}</h2>;
}
if (failed) {
return <h2>Failed to load dynamic script: {meta.url}</h2>;
}
Components[typeId] = lazy(loadComponent(meta.scope, meta.module));
}
return render(Components[typeId]);
};
export default LoadBlock;

View File

@ -0,0 +1,52 @@
/*
Copyright 2020 Lowdefy, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { useQuery, gql } from '@apollo/client';
const getBlock = gql`
query getBlock($id: String!) {
block(id: $id) @client {
id
t
}
}
`;
const WatchCache = ({ block, render, rootContext }) => {
console.log('watch', block);
const { loading, error, data } = useQuery(getBlock, {
variables: {
id: `BlockClass:${block.id}`,
},
client: rootContext.client,
});
if (loading) return 'Loading cache';
if (error) throw error;
// // TODO: move to switch
// if (block.eval.visible === false)
// return <div id={`vs-${block.blockId}`} style={{ display: 'none' }} />;
console.log(block.id, data);
if (data.block.loading) {
return 'Loading data.block.loading';
}
console.log('PASS');
return render();
};
export default WatchCache;

View File

@ -7,11 +7,6 @@ const deps = require('./package.json').dependencies;
module.exports = {
entry: './src/index',
mode: 'development',
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3001,
},
output: {
path: path.resolve(__dirname, 'dist'),
},

View File

@ -0,0 +1,12 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const path = require('path');
module.exports = merge(common, {
mode: 'development',
devtool: 'eval-source-map',
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3001,
},
});

View File

@ -0,0 +1,6 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'production',
});

View File

@ -2846,6 +2846,7 @@ __metadata:
webpack: 5.0.0
webpack-cli: 3.3.12
webpack-dev-server: 3.11.0
webpack-merge: 5.2.0
languageName: unknown
linkType: soft
@ -3035,6 +3036,7 @@ __metadata:
webpack: 5.0.0
webpack-cli: 3.3.12
webpack-dev-server: 3.11.0
webpack-merge: ^5.2.0
languageName: unknown
linkType: soft
@ -18001,6 +18003,16 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"webpack-merge@npm:5.2.0, webpack-merge@npm:^5.2.0":
version: 5.2.0
resolution: "webpack-merge@npm:5.2.0"
dependencies:
clone-deep: ^4.0.1
wildcard: ^2.0.0
checksum: edc100b9c7cfc675d1e1857afd6a194daa1756d140eda9da452e4f73933307a9af7a485a94b68ef29dfa3047f69e34d6ca724e5858109ed287f32b79c0c0d11b
languageName: node
linkType: hard
"webpack-sources@npm:^1.4.3":
version: 1.4.3
resolution: "webpack-sources@npm:1.4.3"
@ -18168,6 +18180,13 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"wildcard@npm:^2.0.0":
version: 2.0.0
resolution: "wildcard@npm:2.0.0"
checksum: 207baede4d6d41fc1aefcc4727c95ca6f29eaaf4d66478665fe0ac17232709637426ae96fd79deb3b68da3564e7bde7f2be63e5c3665ac8f63ee92364c0a2dd3
languageName: node
linkType: hard
"windows-release@npm:^3.1.0":
version: 3.3.3
resolution: "windows-release@npm:3.3.3"