Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/apps/diceroller/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
const testTools = require("@fluidframework/test-tools");
const { name } = require("./package.json");

mappedPort = testTools.getTestPort(name);
const mappedPort = testTools.getTestPort(name);
process.env["PORT"] = mappedPort;

module.exports = {
Expand Down
3 changes: 1 addition & 2 deletions examples/apps/diceroller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@
"@fluidframework/container-definitions": "workspace:~",
"@fluidframework/container-loader": "workspace:~",
"@fluidframework/map": "workspace:~",
"react": "^18.3.1",
"uuid": "^11.1.0"
"react": "^18.3.1"
},
"devDependencies": {
"@biomejs/biome": "~1.9.3",
Expand Down
8 changes: 4 additions & 4 deletions examples/apps/diceroller/src/container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
*/

import { ContainerViewRuntimeFactory } from "@fluid-example/example-utils";
import React from "react";
import { createElement } from "react";

import { type DiceRoller, DiceRollerInstantiationFactory, DiceRollerView } from "./main.js";
import { DiceRollerInstantiationFactory, DiceRollerView, type IDiceRoller } from "./main.js";

const diceRollerViewCallback = (model: DiceRoller): JSX.Element =>
React.createElement(DiceRollerView, { model });
const diceRollerViewCallback = (diceRoller: IDiceRoller): JSX.Element =>
createElement(DiceRollerView, { diceRoller });

/**
* This does setup for the Container. The ContainerViewRuntimeFactory will instantiate a single Fluid object to use
Expand Down
37 changes: 20 additions & 17 deletions examples/apps/diceroller/src/container/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { EventEmitter } from "@fluid-example/example-utils";
import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/legacy";
import type { IValueChanged } from "@fluidframework/map/legacy";
import React from "react";
import React, { type FC, useEffect, useState } from "react";
Comment thread
shlevari marked this conversation as resolved.

const diceValueKey = "diceValue";

Expand All @@ -25,37 +25,40 @@ export interface IDiceRoller extends EventEmitter {
roll: () => void;

/**
* The diceRolled event will fire whenever someone rolls the device, either locally or remotely.
* The diceRolled event will fire whenever someone rolls the dice, either locally or remotely.
*/
on(event: "diceRolled", listener: () => void): this;
}

export interface IDiceRollerViewProps {
model: IDiceRoller;
diceRoller: IDiceRoller;
}

export const DiceRollerView: React.FC<IDiceRollerViewProps> = (
props: IDiceRollerViewProps,
) => {
const [diceValue, setDiceValue] = React.useState(props.model.value);
export const DiceRollerView: FC<IDiceRollerViewProps> = ({
diceRoller,
}: IDiceRollerViewProps) => {
const [diceValue, setDiceValue] = useState(diceRoller.value);

React.useEffect(() => {
useEffect(() => {
const onDiceRolled = (): void => {
setDiceValue(props.model.value);
setDiceValue(diceRoller.value);
};
props.model.on("diceRolled", onDiceRolled);
return () => {
props.model.off("diceRolled", onDiceRolled);
diceRoller.on("diceRolled", onDiceRolled);
return (): void => {
diceRoller.off("diceRolled", onDiceRolled);
};
}, [props.model]);
}, [diceRoller]);

// Unicode 0x2680-0x2685 are the sides of a dice (⚀⚁⚂⚃⚄⚅)
const diceChar = String.fromCodePoint(0x267f + diceValue);
const color = `hsl(${diceValue * 60}, 70%, 50%)`;

return (
<div>
<span style={{ fontSize: 50 }}>{diceChar}</span>
<button onClick={props.model.roll}>Roll</button>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "200px", color }}>{diceChar}</div>
<button style={{ fontSize: "50px" }} onClick={diceRoller.roll}>
Roll
</button>
</div>
);
};
Expand All @@ -64,7 +67,7 @@ export const DiceRollerView: React.FC<IDiceRollerViewProps> = (
* The DiceRoller is our implementation of the IDiceRoller interface.
* @internal
*/
export class DiceRoller extends DataObject implements IDiceRoller {
class DiceRoller extends DataObject implements IDiceRoller {
public static readonly Name = "@fluid-example/dice-roller";

public static readonly factory = new DataObjectFactory({
Expand Down
10 changes: 6 additions & 4 deletions examples/view-integration/container-views/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# @fluid-example/app-integration-container-views

**Dice Roller** is a basic example that has a die and a button. Clicking the button re-rolls the die and persists the value in the root SharedDirectory.
The **container-views** example has a dice and a button. Clicking the button re-rolls the dice and persists the value in a SharedMap.

This implementation demonstrates plugging the container into a standalone application, rather than using the webpack-fluid-loader environment that most of our packages use. This implementation relies on [Tinylicious](/server/routerlicious/packages/tinylicious), so there are a few extra steps to get started. We expect the container to respond with a mountable view that we can use for rendering.
This example demonstrates the container-views pattern. In this pattern, the container code (`src/container/index.ts`) establishes not only the model and controller logic (the DiceRoller, `diceRoller.ts`) but also the view code (`view.tsx`) and its binding to the model/controller. The container's entry point uses `ContainerViewRuntimeFactory` to provide an `IFluidMountableViewEntryPoint` (a mountable view with `mount`/`unmount` methods), so the consumer of the container (`app.ts`) simply mounts it into a DOM element without needing to know about the view implementation.

This is distinct from the external-views pattern, where the container only provides the data model and the consumer is responsible for creating and binding the view. The container-views pattern can be convenient when the container wants to control its own rendering, but may result in less view flexibility and larger-than-necessary bundle size (especially for headless usage). The external-views pattern is therefore recommended over the container-views pattern for general use.

<!-- AUTO-GENERATED-CONTENT:START (EXAMPLE_APP_README_HEADER:usesTinylicious=TRUE) -->

Expand Down Expand Up @@ -43,9 +45,9 @@ For in browser testing update `./jest-puppeteer.config.js` to:

## Data model

Dice Roller uses the following distributed data structures:
DiceRoller uses the following distributed data structures:

- SharedDirectory - root
- SharedMap
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, seems like a good follow up to probably switch our examples to SharedTree since that is what we'd like all new customers to use. But obviously not needed as part of this PR


<!-- AUTO-GENERATED-CONTENT:START (README_FOOTER) -->

Expand Down
4 changes: 2 additions & 2 deletions examples/view-integration/container-views/eslint.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
*/

import type { Linter } from "eslint";
import { minimalDeprecated } from "../../../common/build/eslint-config-fluid/flat.mts";
import { recommended } from "../../../common/build/eslint-config-fluid/flat.mts";
import sharedConfig from "../../eslint.config.data.mts";

const config: Linter.Config[] = [...minimalDeprecated, ...sharedConfig];
const config: Linter.Config[] = [...recommended, ...sharedConfig];

export default config;
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

module.exports = {
server: {
command: `npm run start -- --no-hot --no-live-reload --port ${process.env["PORT"]}`,
command: `npm run start:test -- --no-hot --no-live-reload --port ${process.env["PORT"]}`,
port: process.env["PORT"],
launchTimeout: 10000,
usedPortAction: "error",
Expand Down
2 changes: 1 addition & 1 deletion examples/view-integration/container-views/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
const testTools = require("@fluidframework/test-tools");
const { name } = require("./package.json");

mappedPort = testTools.getTestPort(name);
const mappedPort = testTools.getTestPort(name);
process.env["PORT"] = mappedPort;

module.exports = {
Expand Down
20 changes: 13 additions & 7 deletions examples/view-integration/container-views/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,34 @@
"lint": "fluid-build . --task lint",
"lint:fix": "fluid-build . --task eslint:fix --task format",
"prepack": "npm run webpack",
"start": "webpack serve",
"start": "npm run start:t9s",
"start:local": "webpack serve --env service=local",
"start:odsp": "webpack serve --env service=odsp",
"start:t9s": "webpack serve --env service=t9s",
"start:test": "webpack serve --env service=local",
"test": "npm run test:jest",
"test:jest": "jest --ci",
"test:jest:verbose": "cross-env FLUID_TEST_VERBOSE=1 jest --ci",
"webpack": "webpack --env production",
"webpack:dev": "webpack --env development"
},
"dependencies": {
"@fluid-example/example-driver": "workspace:~",
"@fluid-example/example-utils": "workspace:~",
"@fluidframework/aqueduct": "workspace:~",
"@fluid-internal/client-utils": "workspace:~",
"@fluidframework/container-definitions": "workspace:~",
"@fluidframework/container-loader": "workspace:~",
"@fluidframework/container-runtime-definitions": "workspace:~",
"@fluidframework/core-interfaces": "workspace:~",
"@fluidframework/core-utils": "workspace:~",
"@fluidframework/datastore": "workspace:~",
"@fluidframework/datastore-definitions": "workspace:~",
"@fluidframework/map": "workspace:~",
"@fluidframework/runtime-utils": "workspace:~",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"@fluidframework/runtime-definitions": "workspace:~",
"react": "^18.3.1"
},
"devDependencies": {
"@biomejs/biome": "~1.9.3",
"@fluid-example/example-webpack-integration": "workspace:~",
"@fluid-tools/build-cli": "^0.63.0",
"@fluidframework/build-common": "^2.0.3",
"@fluidframework/build-tools": "^0.63.0",
Expand All @@ -56,7 +63,6 @@
"@types/jest-environment-puppeteer": "workspace:~",
"@types/node": "~20.19.30",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"cross-env": "^10.1.0",
"eslint": "~9.39.1",
"expect-puppeteer": "^9.0.2",
Expand Down
123 changes: 86 additions & 37 deletions examples/view-integration/container-views/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,96 @@
* Licensed under the MIT License.
*/

import { StaticCodeLoader, TinyliciousModelLoader } from "@fluid-example/example-utils";
import {
createExampleDriver,
getSpecifiedServiceFromWebpack,
} from "@fluid-example/example-driver";
import type { IFluidMountableViewEntryPoint } from "@fluid-example/example-utils";
import type {
ICodeDetailsLoader,
IContainer,
IFluidCodeDetails,
IFluidModuleWithDetails,
} from "@fluidframework/container-definitions/legacy";
import {
createDetachedContainer,
loadExistingContainer,
} from "@fluidframework/container-loader/legacy";

import { DiceRollerContainerRuntimeFactory, IMountableViewAppModel } from "./containerCode.js";
import { fluidExport } from "./container/index.js";

/**
* Start the app and render.
*
* @remarks We wrap this in an async function so we can await Fluid's async calls.
*/
async function start(): Promise<void> {
const tinyliciousModelLoader = new TinyliciousModelLoader<IMountableViewAppModel>(
new StaticCodeLoader(new DiceRollerContainerRuntimeFactory()),
);

let id: string;
let model: IMountableViewAppModel;

if (location.hash.length === 0) {
// Normally our code loader is expected to match up with the version passed here.
// But since we're using a StaticCodeLoader that always loads the same runtime factory regardless,
// the version doesn't actually matter.
const createResponse = await tinyliciousModelLoader.createDetached("1.0");
model = createResponse.model;
id = await createResponse.attach();
} else {
id = location.hash.substring(1);
model = await tinyliciousModelLoader.loadExisting(id);
}
const service = getSpecifiedServiceFromWebpack();
const {
urlResolver,
documentServiceFactory,
createCreateNewRequest,
createLoadExistingRequest,
} = await createExampleDriver(service);

// update the browser URL and the window title with the actual container ID
location.hash = id;
document.title = id;
const codeLoader: ICodeDetailsLoader = {
load: async (details: IFluidCodeDetails): Promise<IFluidModuleWithDetails> => {
return {
module: { fluidExport },
details,
};
},
};

const contentDiv = document.getElementById("content") as HTMLDivElement;
// In a production app, we should probably be retaining a reference to mountableView long-term so we can call
// unmount() on it to correctly remove it from the DOM if needed.
model.mountableView.mount(contentDiv);
let id: string;
let container: IContainer;

// Setting "fluidStarted" is just for our test automation
// eslint-disable-next-line @typescript-eslint/dot-notation
window["fluidStarted"] = true;
if (location.hash.length === 0) {
// Some services support or require specifying the container id at attach time (local, odsp). For
// services that do not (t9s), the passed id will be ignored.
id = Date.now().toString();
const createNewRequest = createCreateNewRequest(id);
container = await createDetachedContainer({
codeDetails: { package: "1.0" },
urlResolver,
documentServiceFactory,
codeLoader,
});
await container.attach(createNewRequest);
// For most services, the id on the resolvedUrl is the authoritative source for the container id
// (regardless of whether the id passed in createCreateNewRequest is respected or not). However,
// for odsp the id is a hashed combination of drive and container ID which we can't use. Instead,
// we retain the id we generated above.
if (service !== "odsp") {
if (container.resolvedUrl === undefined) {
throw new Error("Resolved Url unexpectedly missing!");
}
id = container.resolvedUrl.id;
}
} else {
id = location.hash.slice(1);
container = await loadExistingContainer({
request: await createLoadExistingRequest(id),
urlResolver,
documentServiceFactory,
codeLoader,
});
}

start().catch((error) => console.error(error));
// The key difference from external-views: the container entry point provides a mountable view
const { getMountableDefaultView } =
(await container.getEntryPoint()) as IFluidMountableViewEntryPoint;
const mountableView = await getMountableDefaultView();

// Render view
const appDiv = document.createElement("div");
document.body.append(appDiv);
mountableView.mount(appDiv);

// Update url and tab title
location.hash = id;
document.title = id;

// For testing purposes, we expose a way to load an additional instance of the container in the same page
globalThis.loadAdditionalContainer = async () => {
return loadExistingContainer({
request: await createLoadExistingRequest(id),
urlResolver,
documentServiceFactory,
codeLoader,
});
};
Loading
Loading